Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/background-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ opencode(两者源码已核对),要点是「框架兑现叫醒」,而不是靠
- **通知是内部上下文,不是用户发言**:落盘为 user 角色 + `metadata.background_task`。
`llm/message_predicates.hpp` 的 `is_background_task_message` 与网状蜂群的非 NEW_TASK
信封同等对待:不计回合、不当标题 / 摘要、不触发 UserPromptSubmit、不建检查点、
不进分叉历史;Web 转成系统提示行,TUI 显示成系统行。Web 折叠回合时,前一回合
已收尾(task_complete 或助手正文)的通知行开始新回合,前一回合保留自己的总结;
回合中途插入的通知不切分回合(`transcriptProjection.js` 的 `turnHasEnded`)。
不进分叉历史;Web 转成系统提示行但**不画进对话**(用户反馈「后台命令已完成」那行纯属
干扰,结果在摘要面板和详情栏的后台任务页签里看),TUI 显示成系统行。Web 折叠回合时,
前一回合已收尾(task_complete 或助手正文)的通知仍负责开始新回合,前一回合保留自己的
总结;回合中途插入的通知不切分回合(`transcriptProjection.js` 的 `turnHasEnded`);切分后
`projectCollapsedTranscriptItems` 把通知行滤掉,刚被唤醒、还没内容的回合只留实时活动行。
- **转后台的三个时机**:前台命令超时;用户插话(`steer_input` 递增信号,让消息能
马上送到模型);用户手动(Web 活动行「转到后台」、TUI Ctrl+B、`POST /background`)。
Esc 中断回合仍然杀前台命令,不影响已在后台的任务。
Expand Down
182 changes: 182 additions & 0 deletions packaging/understand-anything/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Build the ACECode skill ZIP from the v2.9.0 checkout after pnpm install and core build."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
import zipfile

HERE = Path(__file__).resolve().parent
COMMIT = "f08763d11d0202a8a8f52b5dedda6d1b2e2ebac8"
RELEASE = "https://github.com/Egonex-AI/Understand-Anything/releases/download/v2.9.0"
VIEWER_SHA = "a8626ff3ad90041e807bfdb8994eefdd986e891593c4759d08222667e5405330"


def copy_files(source, target, files):
for path in files:
dest = target / path.relative_to(source)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(path, dest)


def load(path):
return json.loads(path.read_text(encoding="utf-8"))


def dependencies(plugin, root):
installed = {}
def collect(name, parent):
# pnpm exposes direct dependencies next to the real package.
pkg = next((base / name for base in [parent / "node_modules", parent.parent,
*[p / "node_modules" for p in parent.parents]]
if (base / name / "package.json").is_file()), None)
if not pkg:
raise ValueError(f"Missing runtime dependency {name} from {parent}")
pkg = pkg.resolve()
manifest = load(pkg / "package.json")
if name in installed:
if installed[name] != manifest["version"]:
raise ValueError(f"Incompatible versions for {name}")
return
installed[name] = manifest["version"]
dest = root / "node_modules" / name
files = [pkg / "package.json"]
if name == "@understand-anything/core":
files += [p for p in (pkg / "dist").rglob("*.js")
if "__tests__" not in p.parts and not p.name.endswith(".test.js")]
if pkg / "dist/index.js" not in files:
raise ValueError("Build @understand-anything/core first")
elif "tree-sitter" in name and name != "web-tree-sitter":
grammars = list(pkg.glob("*.wasm"))
if not grammars:
raise ValueError(f"No WASM grammar in {name}")
files += grammars
else:
files += [p for p in pkg.rglob("*") if p.is_file()
and not {"node_modules", "debug", "tests", "__tests__", "test", "src"}.intersection(p.relative_to(pkg).parts[:-1])
and p.suffix in (".js", ".mjs", ".cjs", ".json", ".wasm")]
files += [p for p in pkg.iterdir() if p.is_file() and
p.name.lower().startswith(("license", "licence", "copying", "notice", "build.md"))]
copy_files(pkg, dest, files)
if "tree-sitter" not in name:
for dependency in manifest.get("dependencies", {}):
collect(dependency, pkg)
for name in load(plugin / "package.json")["dependencies"]:
collect(name, plugin)
return installed


def assemble(source, root, tgz):
plugin = source / "understand-anything-plugin"
if subprocess.check_output(["git","-C",source,"rev-parse","HEAD"], text=True).strip() != COMMIT:
raise ValueError("Expected the pinned v2.9.0 source commit")
shutil.copytree(plugin / "skills", root / "skills")
shutil.copytree(plugin / "agents", root / "agents")
shutil.copyfile(source / "LICENSE", root / "LICENSE")
shutil.copyfile(plugin / "package.json", root / "package.json")
versions = dependencies(plugin, root)
(root / "UPSTREAM.json").write_text(json.dumps(dict(version="2.9.0", commit=COMMIT,
source="https://github.com/Egonex-AI/Understand-Anything", viewerSHA256=VIEWER_SHA,
dependencies=versions), indent=2) + "\n", encoding="utf-8")
if hashlib.sha256(tgz.read_bytes()).hexdigest() != VIEWER_SHA:
raise ValueError("Viewer archive checksum mismatch")
with tarfile.open(tgz) as archive:
for member in archive.getmembers():
path = Path(member.name)
if not member.isfile() or path.parts[0] != "package" or ".." in path.parts:
raise ValueError(f"Unexpected viewer entry {member.name}")
dest = root / "viewer" / Path(*path.parts[1:])
dest.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as stream, dest.open("wb") as out:
shutil.copyfileobj(stream, out)
# Keep data files per run while the parser still resolves imports against the real checkout.
batch = root / "skills/understand/compute-batches.mjs"
replace_once(batch, "const uaDir = resolveUaDir(projectRoot);",
"const uaDir = process.env.ACE_UA_BATCH_DIR || resolveUaDir(projectRoot);")
scanner = root / "skills/understand/scan-project.mjs"
replace_once(scanner, "extname, relative, sep", "extname, relative, isAbsolute, sep")
replace_once(scanner, " const candidates = enumerateFiles(projectRoot);",
" const physicalRoot = realpathSync(projectRoot);\n"
" const candidates = enumerateFiles(projectRoot);")
replace_once(scanner, " const isIgnoredCombined = combined.isIgnored(rel);",
' if (rel.split(/[\\\\/]/).some(p => p === ".ua" || p === ".understand-anything")) continue;\n'
' const abs = resolve(projectRoot, rel);\n'
' if (abs === pluginRoot || abs.startsWith(pluginRoot + sep)) continue;\n'
' try {\n'
' const physical = realpathSync(abs);\n'
' const outside = relative(physicalRoot, physical);\n'
' if (outside === ".." || outside.startsWith(".." + sep) || isAbsolute(outside)) continue;\n'
' } catch { continue; }\n'
' const isIgnoredCombined = combined.isIgnored(rel);')
for relative in ("understand-domain/extract-domain-context.py",
"understand-knowledge/parse-knowledge-base.py",
"understand-knowledge/merge-knowledge-graph.py"):
replace_once(root / "skills" / relative, " return legacy if legacy.is_dir() else root / \".ua\"",
' if os.environ.get("ACE_UA_SPECIAL_DIR"):\n'
' return Path(os.environ["ACE_UA_SPECIAL_DIR"])\n'
' return legacy if legacy.is_dir() else root / ".ua"')
# The standalone domain workflow may precede any code analysis.
viewer = root / "viewer/bin/viewer.mjs"
replace_once(viewer, '.find((d) => fs.existsSync(path.join(d, "knowledge-graph.json")))',
'.find((d) => ["knowledge-graph.json", "domain-graph.json"].some(f => fs.existsSync(path.join(d, f))))')
replace_once(viewer, 'const candidate = path.join(graphDir, fileName);',
'let candidate = path.join(graphDir, fileName);\n'
' if (fileName === "knowledge-graph.json" && !fs.existsSync(candidate)) {\n'
' candidate = path.join(graphDir, "domain-graph.json");\n }')
# Collect attribution files from the pinned dashboard dependency installation as well.
for pkg in (plugin / "node_modules/.pnpm").glob("*/node_modules/*"):
if pkg.name.startswith(".") or pkg.is_symlink() or not pkg.is_dir():
continue
candidates = pkg.iterdir() if pkg.name.startswith("@") else [pkg]
for actual in candidates:
if not (actual / "package.json").is_file():
continue
m = load(actual / "package.json")
dest = root / "licenses" / (m["name"].replace("/", "__") + "-" + m["version"])
copy_files(actual, dest, [p for p in actual.iterdir() if p.is_file()
and p.name.lower().startswith(("license", "licence", "copying", "notice"))])
shutil.copytree(HERE / "bundle", root, dirs_exist_ok=True)


def replace_once(path, original, replacement):
text = path.read_text(encoding="utf-8")
if text.count(original) != 1:
raise ValueError(f"Unexpected upstream source in {path.name}")
path.write_text(text.replace(original, replacement), encoding="utf-8", newline="\n")


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--viewer", type=Path, help="Previously downloaded official viewer tgz")
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as directory:
temp = Path(directory)
tgz = args.viewer
if not tgz:
tgz = temp / "viewer.tgz"
urllib.request.urlretrieve(RELEASE + "/understand-anything-viewer.tgz", tgz)
root = temp / "understand-anything"
assemble(args.source.resolve(), root, tgz)
fd, filename = tempfile.mkstemp(dir=args.output.parent, suffix=".zip")
os.close(fd)
try:
with zipfile.ZipFile(filename, "w", zipfile.ZIP_DEFLATED, compresslevel=7) as out:
for f in sorted(root.rglob("*")):
if f.is_file():
out.write(f, f.relative_to(temp))
os.replace(filename, args.output)
finally:
Path(filename).unlink(missing_ok=True)
print(args.output.resolve())


if __name__ == "__main__":
main()
28 changes: 28 additions & 0 deletions packaging/understand-anything/bundle/INSTALL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Understand Anything for ACECode

上游 v2.9.0 · ACECode 适配版 1

## 安装

将 ZIP 中的整个 `understand-anything` 文件夹解压到 ACECode 的技能扫描目录。默认用户目录为 `~/.acecode/skills/`,也可以放在项目的 `.acecode/skills/`。使用自定义数据目录时,在 ACECode 的技能设置中查看实际扫描路径。解压后应存在 `understand-anything/skills/understand/SKILL.md`。共享脚本、node_modules、viewer 与九个技能须放在同一个包内。已有同名技能时,请检查技能列表中的实际来源。

在 ACECode 运行 `/skills reload` 或重新打开会话。运行环境需要 Node.js 22+ 和 Python 3.11+。代码分析使用当前 ACECode 会话的模型。Git 支持版本识别和差异分析。解析器、语言语法和本地 Viewer 已包含在包中。

## 使用

```text
/understand --language zh
/understand-chat 应用从哪里启动?
/understand-explain src/main.cpp
/understand-diff
/understand-onboard
/understand-dashboard
```

`/understand-domain` 生成业务流程图,`/understand-knowledge <Wiki目录>` 分析 Markdown Wiki,`/understand-figma <URL>` 分析 Figma 设计。Figma 需要环境变量 `FIGMA_TOKEN` 和网络。

新项目保存到 `.ua/`,已有 `.understand-anything/` 的项目沿用该目录。再次运行 `/understand` 会比较文件内容。首次读取旧版图谱可直接问答或查看,首次用适配版刷新时会重新分析。大仓库可传子目录或配置数据目录内的 `.understandignore` 控制范围。

`/understand --auto-update` 为当前项目登记 ACECode 钩子,按 ACECode 的提示审核并重新加载后生效。`--no-auto-update` 取消。搬动安装位置后重新开启一次。如果进程在发布期间被强制中断,下次发布会报告已有锁。确认对应 PID 已结束后可移除数据目录内的 `acecode-publish.lock` 并重试。

上游源码版本、Viewer 校验值、依赖版本记录在 `UPSTREAM.json`。授权文本见 `LICENSE`、`licenses/` 和各运行时组件目录。
72 changes: 72 additions & 0 deletions packaging/understand-anything/bundle/scripts/refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Optional ACECode hook for refreshing a graph after a new Git revision."""
import json
from pathlib import Path
import re
import sys

from ua import data_dir, read, revision, write

LABEL = "Understand Anything graph refresh"


def configure(project, enabled):
path = project / ".acecode/hooks.json"
config = read(path) if path.exists() else {}
handlers = config.setdefault("hooks", {})
# ACECode launches project hooks in the workspace. Keep the registered command project-relative.
local = project / ".acecode/understand-anything-refresh.py"
script = Path(__file__).resolve()
launcher = "# Managed by Understand Anything for ACECode\nimport runpy\nimport sys\n"
launcher += f"sys.path.insert(0, {str(script.parent)!r})\nrunpy.run_path({str(script)!r}, run_name='__main__')\n"
if local.exists() and not local.read_text(encoding="utf-8").startswith("# Managed by Understand Anything for ACECode\n"):
raise ValueError(f"Another file already exists at {local}")
for event, matcher in (("SessionStart", "*"), ("PostToolUse", "Bash|bash|exec_command|shell")):
groups = []
for group in handlers.get(event, []):
kept = [h for h in group["hooks"] if h.get("statusMessage") != LABEL]
if kept:
groups.append(dict(group, hooks=kept))
if enabled:
groups.append({"matcher": matcher, "hooks": [{"type": "command", "statusMessage": LABEL,
"command": "python3 .acecode/understand-anything-refresh.py",
"commandWindows": "python .acecode/understand-anything-refresh.py", "timeout": 5}]})
if groups:
handlers[event] = groups
else:
handlers.pop(event, None)
if enabled:
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text(launcher, encoding="utf-8")
if enabled or path.exists():
write(path, config)
if not enabled:
local.unlink(missing_ok=True)
settings = data_dir(project) / "config.json"
current = read(settings) if settings.is_file() else {}
write(settings, {**current, "autoUpdate": enabled})
return {"autoUpdate": enabled, "hooks": str(path)}


def event(payload):
project = Path(payload["cwd"])
data = data_dir(project)
opts = data / "config.json"
if not opts.is_file() or not read(opts).get("autoUpdate") or not (data / "knowledge-graph.json").is_file():
return
event_name = payload.get("hook_event_name")
if event_name == "PostToolUse":
args = payload.get("tool_input", {})
command = args.get("command", args.get("cmd", "")) if isinstance(args, dict) else ""
if not re.search(r"\bgit\s+(?:commit|merge|cherry-pick|rebase|pull)\b", command):
return
elif event_name != "SessionStart":
return
meta = data / "meta.json"
commit = revision(project)
if commit and meta.is_file() and read(meta).get("gitCommitHash") != commit:
print(json.dumps({"hookSpecificOutput": {"hookEventName": event_name, "additionalContext":
"This project's Understand Anything graph predates HEAD. Run /understand for an incremental update."}}))


if __name__ == "__main__":
event(json.load(sys.stdin))
Loading
Loading