dev-memory-cli 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/bin/dev-memory.js +74 -0
- package/lib/dev_memory_capture.py +335 -2
- package/package.json +1 -1
- package/scripts/hooks/_common.py +104 -30
package/README.md
CHANGED
|
@@ -151,10 +151,16 @@ apply 写入受 **repo 级队列锁**保护:多个会话可连续发起归档
|
|
|
151
151
|
| 模式 | 触发条件 | 行为 |
|
|
152
152
|
| --- | --- | --- |
|
|
153
153
|
| 单 repo | cwd 本身是 git 仓库 | 最原始行为,所有 hook/skill 直接作用于当前 repo+branch |
|
|
154
|
-
| Workspace | cwd 不是 git 仓库,但第一级子目录里至少有一个 git 仓库 | SessionStart 为 primary 仓库注入完整记忆 +
|
|
154
|
+
| Workspace | cwd 不是 git 仓库,但第一级子目录里至少有一个 git 仓库 | SessionStart 为 primary 仓库注入完整记忆 + 其它仓库按数量动态精简的 brief;Stop/PreCompact/SessionEnd 对每个仓库各记一次 HEAD;skill 通过 `--repo <basename>` 明确目标仓库 |
|
|
155
155
|
| No-git | cwd 不是 git 仓库,也不是 workspace | 在当前目录落一个 `.dev-memory-id` dotfile 作为仓库身份,分支层退化成单一共享层(sentinel `_no_git`),`dev-memory-graduate` 此模式下直接拒绝 |
|
|
156
156
|
|
|
157
|
-
`DEV_ASSETS_PRIMARY_REPO`
|
|
157
|
+
Primary 仓库优先读 `DEV_MEMORY_PRIMARY_REPO` / `DEV_ASSETS_PRIMARY_REPO` 环境变量;未设置时读 workspace 根目录下的 `.dev-memory-workspace.json`:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
dev-memory-cli workspace primary <repo-basename>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`<repo-basename>` 是仓库目录名,不是绝对路径。环境变量适合临时覆盖;`.dev-memory-workspace.json` 适合多个 workspace 各自保存不同 primary。
|
|
158
164
|
|
|
159
165
|
## 存储布局
|
|
160
166
|
|
package/bin/dev-memory.js
CHANGED
|
@@ -8,6 +8,7 @@ const os = require("node:os");
|
|
|
8
8
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
9
9
|
const DEFAULT_STORAGE_ROOT = path.join(os.homedir(), ".dev-memory", "repos");
|
|
10
10
|
const DEFAULT_CONFIG_PATH = process.env.DEV_MEMORY_CONFIG_PATH || path.join(os.homedir(), ".dev-memory", "config.json");
|
|
11
|
+
const WORKSPACE_CONFIG_NAME = ".dev-memory-workspace.json";
|
|
11
12
|
|
|
12
13
|
function fail(message) {
|
|
13
14
|
process.stderr.write(`ERROR: ${message}\n`);
|
|
@@ -281,6 +282,74 @@ function commandUi(_positional, options) {
|
|
|
281
282
|
start({ host, port, openBrowserFlag, readOnly });
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
function listWorkspaceRepos(workspaceRoot) {
|
|
286
|
+
if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
return fs.readdirSync(workspaceRoot, { withFileTypes: true })
|
|
290
|
+
.filter((entry) => entry.isDirectory())
|
|
291
|
+
.map((entry) => path.join(workspaceRoot, entry.name))
|
|
292
|
+
.filter((entryPath) => fs.existsSync(path.join(entryPath, ".git")))
|
|
293
|
+
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readWorkspaceConfig(workspaceRoot) {
|
|
297
|
+
const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_NAME);
|
|
298
|
+
if (!fs.existsSync(configPath)) return {};
|
|
299
|
+
const data = loadJson(configPath);
|
|
300
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function commandWorkspace(rawArgs) {
|
|
304
|
+
const sub = rawArgs[0];
|
|
305
|
+
const { positional, options } = parseArgs(rawArgs.slice(1));
|
|
306
|
+
const workspaceRoot = path.resolve(options.workspace || options.repo || process.cwd());
|
|
307
|
+
const repos = listWorkspaceRepos(workspaceRoot);
|
|
308
|
+
const repoNames = repos.map((repoPath) => path.basename(repoPath));
|
|
309
|
+
|
|
310
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
311
|
+
process.stdout.write(`Usage:
|
|
312
|
+
dev-memory-cli workspace show [--workspace PATH]
|
|
313
|
+
dev-memory-cli workspace primary <repo-basename> [--workspace PATH]
|
|
314
|
+
`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (sub === "show") {
|
|
319
|
+
const config = readWorkspaceConfig(workspaceRoot);
|
|
320
|
+
process.stdout.write(`${JSON.stringify({
|
|
321
|
+
workspace: workspaceRoot,
|
|
322
|
+
config_path: path.join(workspaceRoot, WORKSPACE_CONFIG_NAME),
|
|
323
|
+
primary_repo: config.primary_repo || null,
|
|
324
|
+
repos: repoNames,
|
|
325
|
+
}, null, 2)}\n`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (sub === "primary") {
|
|
330
|
+
const primary = positional[0];
|
|
331
|
+
if (!primary || primary === true) {
|
|
332
|
+
fail("workspace primary requires a repo basename");
|
|
333
|
+
}
|
|
334
|
+
if (!repoNames.includes(primary)) {
|
|
335
|
+
fail(`repo '${primary}' not found under ${workspaceRoot}. Available: ${repoNames.join(", ") || "(none)"}`);
|
|
336
|
+
}
|
|
337
|
+
const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_NAME);
|
|
338
|
+
const config = readWorkspaceConfig(workspaceRoot);
|
|
339
|
+
config.primary_repo = primary;
|
|
340
|
+
config.updated_at = new Date().toISOString();
|
|
341
|
+
writeJson(configPath, config);
|
|
342
|
+
process.stdout.write(`${JSON.stringify({
|
|
343
|
+
workspace: workspaceRoot,
|
|
344
|
+
config_path: configPath,
|
|
345
|
+
primary_repo: primary,
|
|
346
|
+
}, null, 2)}\n`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fail(`unknown workspace command: ${sub}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
284
353
|
// Subcommands that delegate straight to the Python lib/ scripts.
|
|
285
354
|
// Their CLIs (subcommands, flags) are owned by argparse on the Python
|
|
286
355
|
// side, so we deliberately bypass parseArgs and forward raw argv.
|
|
@@ -617,6 +686,7 @@ function printHelp() {
|
|
|
617
686
|
dev-memory-cli install-hooks <codex|claude> [--repo PATH] [--global]
|
|
618
687
|
dev-memory-cli install-hooks --all [--repo PATH] [--global]
|
|
619
688
|
dev-memory-cli ui [--port N] [--host HOST] [--no-open] [--read-only]
|
|
689
|
+
dev-memory-cli workspace <show|primary> [...]
|
|
620
690
|
dev-memory-cli read <show|search> [...]
|
|
621
691
|
dev-memory-cli context <show|...> [...]
|
|
622
692
|
dev-memory-cli capture <record|show|sync-working-tree|record-head|suggest-kind|classify> [...]
|
|
@@ -662,6 +732,10 @@ function main() {
|
|
|
662
732
|
commandUi(positional, options);
|
|
663
733
|
return;
|
|
664
734
|
}
|
|
735
|
+
if (command === "workspace") {
|
|
736
|
+
commandWorkspace(argv.slice(1));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
665
739
|
fail(`unknown command: ${command}`);
|
|
666
740
|
}
|
|
667
741
|
|
|
@@ -162,6 +162,20 @@ SUPERSEDES_KEYWORDS = (
|
|
|
162
162
|
_SIM_PREVIEW_LEN = 80
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
def _int_env(name, default):
|
|
166
|
+
value = os.environ.get(name, "").strip()
|
|
167
|
+
if not value:
|
|
168
|
+
return default
|
|
169
|
+
try:
|
|
170
|
+
return int(value)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return default
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_REPO_SHARED_MAX_ENTRIES = _int_env("DEV_MEMORY_REPO_SHARED_MAX_ENTRIES", 20)
|
|
176
|
+
_REPO_SHARED_MAX_ENTRY_CHARS = _int_env("DEV_MEMORY_REPO_SHARED_MAX_ENTRY_CHARS", 260)
|
|
177
|
+
|
|
178
|
+
|
|
165
179
|
def _first_nonempty_line(text):
|
|
166
180
|
"""Return the first non-blank line of `text`, stripped. Empty string if
|
|
167
181
|
`text` is all blank. Used as the comparison anchor for similarity_check
|
|
@@ -435,6 +449,121 @@ def _check_dedup_for_kind(paths, kind, body, force=False, title_override=None, t
|
|
|
435
449
|
return _build_dedup_hint(kind, file_key, section_title, body, matches)
|
|
436
450
|
|
|
437
451
|
|
|
452
|
+
def _iter_search_targets(kind=None):
|
|
453
|
+
seen = set()
|
|
454
|
+
if kind:
|
|
455
|
+
spec = KIND_MAP.get(kind)
|
|
456
|
+
if not spec:
|
|
457
|
+
raise RuntimeError(f"unsupported kind: {kind}")
|
|
458
|
+
if spec.get("default_mode") != "append":
|
|
459
|
+
raise RuntimeError(f"kind {kind!r} is not append-style and cannot be searched for entry candidates")
|
|
460
|
+
targets = [(kind, spec)]
|
|
461
|
+
else:
|
|
462
|
+
targets = sorted(KIND_MAP.items())
|
|
463
|
+
for kind_name, spec in targets:
|
|
464
|
+
if spec.get("default_mode") != "append":
|
|
465
|
+
continue
|
|
466
|
+
file_key = spec["file"]
|
|
467
|
+
section_title = spec["section"]
|
|
468
|
+
key = (file_key, section_title)
|
|
469
|
+
if key in seen:
|
|
470
|
+
continue
|
|
471
|
+
seen.add(key)
|
|
472
|
+
yield kind_name, file_key, section_title
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _score_candidate(query, entry_text):
|
|
476
|
+
query = (query or "").strip()
|
|
477
|
+
if not query:
|
|
478
|
+
return 0.0
|
|
479
|
+
query_key = _strip_bullet_prefix(_first_nonempty_line(query))[:_SIM_PREVIEW_LEN]
|
|
480
|
+
first_key = _strip_bullet_prefix(_first_nonempty_line(entry_text))[:_SIM_PREVIEW_LEN]
|
|
481
|
+
full_key = " ".join(_strip_bullet_prefix(entry_text).split())[: max(_SIM_PREVIEW_LEN * 3, 1)]
|
|
482
|
+
first_ratio = difflib.SequenceMatcher(None, query_key, first_key).ratio() if first_key else 0.0
|
|
483
|
+
full_ratio = difflib.SequenceMatcher(None, query_key, full_key).ratio() if full_key else 0.0
|
|
484
|
+
return round(max(first_ratio, full_ratio), 4)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def find_entry_candidates(paths, query, *, kind=None, limit=8, min_score=0.2):
|
|
488
|
+
"""Search existing append-style memory entries for a correction target.
|
|
489
|
+
|
|
490
|
+
This is intentionally broader than dedup. Dedup protects append writes
|
|
491
|
+
from near duplicates in a single target section; correction workflows need
|
|
492
|
+
a read-only way to locate the old entry even when the replacement text is
|
|
493
|
+
not similar enough to trip the append-time threshold.
|
|
494
|
+
"""
|
|
495
|
+
if not (query or "").strip():
|
|
496
|
+
raise RuntimeError("one of --query / --query-file is required")
|
|
497
|
+
candidates = []
|
|
498
|
+
for _kind_name, file_key, section_title in _iter_search_targets(kind):
|
|
499
|
+
target_path = paths[file_key]
|
|
500
|
+
if not target_path.exists():
|
|
501
|
+
continue
|
|
502
|
+
content = target_path.read_text(encoding="utf-8")
|
|
503
|
+
_prefix, sections = split_sections(content)
|
|
504
|
+
for section_idx, (title, body) in enumerate(sections):
|
|
505
|
+
if title.strip() != section_title.strip():
|
|
506
|
+
continue
|
|
507
|
+
for entry_idx, entry_text in _section_top_level_entries(body):
|
|
508
|
+
if _is_placeholder_entry(entry_text):
|
|
509
|
+
continue
|
|
510
|
+
score = _score_candidate(query, entry_text)
|
|
511
|
+
if score < min_score:
|
|
512
|
+
continue
|
|
513
|
+
candidates.append({
|
|
514
|
+
"id": f"{file_key}::{section_idx}::{entry_idx}",
|
|
515
|
+
"score": score,
|
|
516
|
+
"file": _label(file_key),
|
|
517
|
+
"section": title,
|
|
518
|
+
"match_first_line": _strip_bullet_prefix(_first_nonempty_line(entry_text)),
|
|
519
|
+
"match_full_text": entry_text,
|
|
520
|
+
})
|
|
521
|
+
candidates.sort(key=lambda item: item["score"], reverse=True)
|
|
522
|
+
return candidates[:limit]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def list_section_entries(paths, kind, *, limit=80, tail=False):
|
|
526
|
+
if kind not in KIND_MAP:
|
|
527
|
+
raise RuntimeError(f"unsupported kind: {kind}")
|
|
528
|
+
spec = KIND_MAP[kind]
|
|
529
|
+
file_key = spec["file"]
|
|
530
|
+
section_title = spec["section"]
|
|
531
|
+
target_path = paths[file_key]
|
|
532
|
+
entries = []
|
|
533
|
+
if target_path.exists():
|
|
534
|
+
content = target_path.read_text(encoding="utf-8")
|
|
535
|
+
_prefix, sections = split_sections(content)
|
|
536
|
+
for section_idx, (title, body) in enumerate(sections):
|
|
537
|
+
if title.strip() != section_title.strip():
|
|
538
|
+
continue
|
|
539
|
+
for entry_idx, entry_text in _section_top_level_entries(body):
|
|
540
|
+
if _is_placeholder_entry(entry_text):
|
|
541
|
+
continue
|
|
542
|
+
entries.append({
|
|
543
|
+
"id": f"{file_key}::{section_idx}::{entry_idx}",
|
|
544
|
+
"entry_idx": entry_idx,
|
|
545
|
+
"file": _label(file_key),
|
|
546
|
+
"section": title,
|
|
547
|
+
"first_line": _strip_bullet_prefix(_first_nonempty_line(entry_text)),
|
|
548
|
+
"full_text": entry_text,
|
|
549
|
+
})
|
|
550
|
+
total = len(entries)
|
|
551
|
+
if limit is not None:
|
|
552
|
+
shown = entries[-limit:] if tail else entries[:limit]
|
|
553
|
+
else:
|
|
554
|
+
shown = entries
|
|
555
|
+
return {
|
|
556
|
+
"kind": kind,
|
|
557
|
+
"target_file": _label(file_key),
|
|
558
|
+
"section": section_title,
|
|
559
|
+
"total_entries": total,
|
|
560
|
+
"returned_entries": len(shown),
|
|
561
|
+
"truncated": len(shown) < total,
|
|
562
|
+
"tail": bool(tail),
|
|
563
|
+
"entries": shown,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
438
567
|
# ---------------------------------------------------------------------------
|
|
439
568
|
# rewrite-entry primitives
|
|
440
569
|
# ---------------------------------------------------------------------------
|
|
@@ -892,6 +1021,110 @@ def decision_body(item):
|
|
|
892
1021
|
return "\n".join(parts)
|
|
893
1022
|
|
|
894
1023
|
|
|
1024
|
+
def compact_decision_body(item):
|
|
1025
|
+
"""Render a repo-shared decision as one short bullet.
|
|
1026
|
+
|
|
1027
|
+
Branch decisions can afford reason/impact substructure because they are
|
|
1028
|
+
read on demand. Repo shared decisions are injected into every branch, so
|
|
1029
|
+
each item must be a compact standalone rule.
|
|
1030
|
+
"""
|
|
1031
|
+
summary = _decision_summary(item)
|
|
1032
|
+
if not isinstance(item, dict):
|
|
1033
|
+
return f"- {summary}"
|
|
1034
|
+
extras = []
|
|
1035
|
+
reason = str(item.get("reason") or "").strip()
|
|
1036
|
+
impact = str(item.get("impact") or "").strip()
|
|
1037
|
+
if reason:
|
|
1038
|
+
extras.append(f"原因: {reason}")
|
|
1039
|
+
if impact:
|
|
1040
|
+
extras.append(f"适用: {impact}")
|
|
1041
|
+
suffix = f"({';'.join(extras)})" if extras else ""
|
|
1042
|
+
return f"- {summary}{suffix}"
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _compact_repo_shared_entry(entry, max_chars):
|
|
1046
|
+
text = (entry or "").strip()
|
|
1047
|
+
if not text:
|
|
1048
|
+
return ""
|
|
1049
|
+
if len(text) <= max_chars:
|
|
1050
|
+
return text
|
|
1051
|
+
first = _strip_bullet_prefix(_first_nonempty_line(text))
|
|
1052
|
+
if len(first) > max_chars:
|
|
1053
|
+
first = first[: max_chars - 1].rstrip() + "…"
|
|
1054
|
+
return f"- {first}"
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_chars):
|
|
1058
|
+
if max_entries <= 0:
|
|
1059
|
+
return None
|
|
1060
|
+
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
1061
|
+
prefix, sections = split_sections(content)
|
|
1062
|
+
changed = False
|
|
1063
|
+
updated = []
|
|
1064
|
+
result = None
|
|
1065
|
+
for existing_title, existing_body in sections:
|
|
1066
|
+
if existing_title.strip() != section_title.strip():
|
|
1067
|
+
updated.append((existing_title, existing_body))
|
|
1068
|
+
continue
|
|
1069
|
+
entries = [entry for _idx, entry in _section_top_level_entries(existing_body)]
|
|
1070
|
+
if not entries:
|
|
1071
|
+
updated.append((existing_title, existing_body))
|
|
1072
|
+
continue
|
|
1073
|
+
kept = entries[-max_entries:]
|
|
1074
|
+
compacted = [_compact_repo_shared_entry(entry, max_entry_chars) for entry in kept]
|
|
1075
|
+
compacted = [entry for entry in compacted if entry]
|
|
1076
|
+
new_body = "\n\n".join(compacted)
|
|
1077
|
+
pruned_count = max(0, len(entries) - len(kept))
|
|
1078
|
+
if new_body.strip() != existing_body.strip():
|
|
1079
|
+
changed = True
|
|
1080
|
+
result = {
|
|
1081
|
+
"file": "repo/decisions.md" if path.name == "decisions.md" else "repo/glossary.md",
|
|
1082
|
+
"section": existing_title,
|
|
1083
|
+
"mode": "prune",
|
|
1084
|
+
"before": len(entries),
|
|
1085
|
+
"after": len(compacted),
|
|
1086
|
+
"pruned": pruned_count,
|
|
1087
|
+
"max_entries": max_entries,
|
|
1088
|
+
"max_entry_chars": max_entry_chars,
|
|
1089
|
+
}
|
|
1090
|
+
updated.append((existing_title, new_body))
|
|
1091
|
+
if not changed:
|
|
1092
|
+
return None
|
|
1093
|
+
path.write_text(join_sections(prefix, updated), encoding="utf-8")
|
|
1094
|
+
return result
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def prune_repo_shared_memory(paths, *, max_entries=None, max_entry_chars=None):
|
|
1098
|
+
"""Keep repo-shared memory small enough for SessionStart injection.
|
|
1099
|
+
|
|
1100
|
+
This is intentionally deterministic and conservative: newest entries win,
|
|
1101
|
+
each entry is capped to its leading claim, and only repo shared sections are
|
|
1102
|
+
touched. Deeper semantic cleanup still belongs to tidy/graduate.
|
|
1103
|
+
"""
|
|
1104
|
+
max_entries = _REPO_SHARED_MAX_ENTRIES if max_entries is None else max_entries
|
|
1105
|
+
max_entry_chars = _REPO_SHARED_MAX_ENTRY_CHARS if max_entry_chars is None else max_entry_chars
|
|
1106
|
+
targets = (
|
|
1107
|
+
("repo_decisions", "跨分支通用决策"),
|
|
1108
|
+
("repo_glossary", "长期有效背景"),
|
|
1109
|
+
("repo_glossary", "共享入口"),
|
|
1110
|
+
("repo_glossary", "共享注意点"),
|
|
1111
|
+
)
|
|
1112
|
+
touched = []
|
|
1113
|
+
for file_key, section in targets:
|
|
1114
|
+
path = paths.get(file_key)
|
|
1115
|
+
if not path:
|
|
1116
|
+
continue
|
|
1117
|
+
rec = _prune_repo_shared_section(
|
|
1118
|
+
path,
|
|
1119
|
+
section,
|
|
1120
|
+
max_entries=max_entries,
|
|
1121
|
+
max_entry_chars=max_entry_chars,
|
|
1122
|
+
)
|
|
1123
|
+
if rec:
|
|
1124
|
+
touched.append(rec)
|
|
1125
|
+
return touched
|
|
1126
|
+
|
|
1127
|
+
|
|
895
1128
|
# ---------------------------------------------------------------------------
|
|
896
1129
|
# Write primitives
|
|
897
1130
|
# ---------------------------------------------------------------------------
|
|
@@ -1093,6 +1326,68 @@ def command_classify(args):
|
|
|
1093
1326
|
return 0
|
|
1094
1327
|
|
|
1095
1328
|
|
|
1329
|
+
def command_find_candidates(args):
|
|
1330
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1331
|
+
args.repo, args.context_dir, args.branch
|
|
1332
|
+
)
|
|
1333
|
+
query = _load_optional_text(args.query, args.query_file)
|
|
1334
|
+
if not query:
|
|
1335
|
+
raise RuntimeError("one of --query / --query-file is required")
|
|
1336
|
+
min_score = getattr(args, "min_score", 0.2)
|
|
1337
|
+
if not (0.0 <= min_score <= 1.0):
|
|
1338
|
+
raise RuntimeError(f"--min-score must be in [0.0, 1.0], got {min_score}")
|
|
1339
|
+
if args.limit < 1:
|
|
1340
|
+
raise RuntimeError(f"--limit must be >= 1, got {args.limit}")
|
|
1341
|
+
candidates = find_entry_candidates(
|
|
1342
|
+
paths,
|
|
1343
|
+
query,
|
|
1344
|
+
kind=args.kind,
|
|
1345
|
+
limit=args.limit,
|
|
1346
|
+
min_score=min_score,
|
|
1347
|
+
)
|
|
1348
|
+
print(json.dumps({
|
|
1349
|
+
"repo_root": str(repo_root),
|
|
1350
|
+
"repo_key": repo_key,
|
|
1351
|
+
"branch": branch_name,
|
|
1352
|
+
"storage_root": str(storage_root),
|
|
1353
|
+
"branch_dir": str(branch_dir),
|
|
1354
|
+
"mode": "find-candidates",
|
|
1355
|
+
"query_preview": _first_nonempty_line(query)[:_SIM_PREVIEW_LEN],
|
|
1356
|
+
"kind": args.kind,
|
|
1357
|
+
"candidates": candidates,
|
|
1358
|
+
"next_actions": [
|
|
1359
|
+
"同一旧条目需要更新: dev-memory-cli capture rewrite-entry --id <id> --content '<new text>'",
|
|
1360
|
+
"旧条目应移除: dev-memory-cli capture delete-entry --id <id>",
|
|
1361
|
+
"没有对应旧条目: dev-memory-cli capture record --kind <kind> --content '<new text>'",
|
|
1362
|
+
],
|
|
1363
|
+
}, ensure_ascii=False, indent=2))
|
|
1364
|
+
return 0
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def command_list_entries(args):
|
|
1368
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1369
|
+
args.repo, args.context_dir, args.branch
|
|
1370
|
+
)
|
|
1371
|
+
if args.limit < 1:
|
|
1372
|
+
raise RuntimeError(f"--limit must be >= 1, got {args.limit}")
|
|
1373
|
+
result = list_section_entries(paths, args.kind, limit=args.limit, tail=args.tail)
|
|
1374
|
+
print(json.dumps({
|
|
1375
|
+
"repo_root": str(repo_root),
|
|
1376
|
+
"repo_key": repo_key,
|
|
1377
|
+
"branch": branch_name,
|
|
1378
|
+
"storage_root": str(storage_root),
|
|
1379
|
+
"branch_dir": str(branch_dir),
|
|
1380
|
+
"mode": "list-entries",
|
|
1381
|
+
**result,
|
|
1382
|
+
"next_actions": [
|
|
1383
|
+
"同一旧条目需要更新: dev-memory-cli capture rewrite-entry --id <id> --content '<new text>'",
|
|
1384
|
+
"旧条目应移除: dev-memory-cli capture delete-entry --id <id>",
|
|
1385
|
+
"没有对应旧条目: dev-memory-cli capture record --kind <kind> --content '<new text>'",
|
|
1386
|
+
],
|
|
1387
|
+
}, ensure_ascii=False, indent=2))
|
|
1388
|
+
return 0
|
|
1389
|
+
|
|
1390
|
+
|
|
1096
1391
|
def command_record(args):
|
|
1097
1392
|
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1098
1393
|
args.repo, args.context_dir, args.branch
|
|
@@ -1215,7 +1510,7 @@ def command_record(args):
|
|
|
1215
1510
|
shared_decision_payload_items = [
|
|
1216
1511
|
item for item in (payload.get("shared_decisions") or []) if _decision_summary(item)
|
|
1217
1512
|
]
|
|
1218
|
-
shared_decision_items = [
|
|
1513
|
+
shared_decision_items = [compact_decision_body(item) for item in shared_decision_payload_items]
|
|
1219
1514
|
if shared_decision_items:
|
|
1220
1515
|
body = "\n\n".join(shared_decision_items)
|
|
1221
1516
|
rec = _write_or_block("shared-decision", body, source_label="payload[shared_decisions]")
|
|
@@ -1550,7 +1845,7 @@ def command_apply_summary_output(args):
|
|
|
1550
1845
|
for item in payload.get("glossary") or []:
|
|
1551
1846
|
add_write("glossary", item, source="glossary")
|
|
1552
1847
|
for item in payload.get("shared_decisions") or []:
|
|
1553
|
-
add_write("shared-decision",
|
|
1848
|
+
add_write("shared-decision", compact_decision_body(item), source="shared_decisions")
|
|
1554
1849
|
for item in payload.get("shared_context") or []:
|
|
1555
1850
|
add_write("shared-context", item, source="shared_context")
|
|
1556
1851
|
for item in payload.get("shared_sources") or []:
|
|
@@ -1599,6 +1894,21 @@ def command_apply_summary_output(args):
|
|
|
1599
1894
|
"reason": item.get("reason"),
|
|
1600
1895
|
})
|
|
1601
1896
|
|
|
1897
|
+
pruned = prune_repo_shared_memory(paths)
|
|
1898
|
+
if pruned:
|
|
1899
|
+
touched.extend(pruned)
|
|
1900
|
+
for rec in pruned:
|
|
1901
|
+
actions.append({
|
|
1902
|
+
"op": "prune-repo-shared",
|
|
1903
|
+
"file": rec["file"],
|
|
1904
|
+
"section": rec["section"],
|
|
1905
|
+
"before": rec["before"],
|
|
1906
|
+
"after": rec["after"],
|
|
1907
|
+
"pruned": rec["pruned"],
|
|
1908
|
+
"max_entries": rec["max_entries"],
|
|
1909
|
+
"max_entry_chars": rec["max_entry_chars"],
|
|
1910
|
+
})
|
|
1911
|
+
|
|
1602
1912
|
updated_at = now_iso()
|
|
1603
1913
|
|
|
1604
1914
|
if touched:
|
|
@@ -1813,6 +2123,27 @@ def main():
|
|
|
1813
2123
|
classify.add_argument("--content", help="Inline content to classify")
|
|
1814
2124
|
classify.add_argument("--content-file", help="File containing content to classify")
|
|
1815
2125
|
|
|
2126
|
+
list_entries = subparsers.add_parser(
|
|
2127
|
+
"list-entries",
|
|
2128
|
+
help="List existing entries in a kind's target section with rewrite/delete ids",
|
|
2129
|
+
)
|
|
2130
|
+
_add_common_args(list_entries)
|
|
2131
|
+
list_entries.add_argument("--kind", required=True, help=f"Kind whose target section should be listed. One of: {', '.join(sorted(KIND_MAP.keys()))}")
|
|
2132
|
+
list_entries.add_argument("--limit", type=int, default=80, help="Maximum number of entries to return")
|
|
2133
|
+
list_entries.add_argument("--tail", action="store_true", help="Return the newest entries instead of the oldest when limited")
|
|
2134
|
+
|
|
2135
|
+
find_candidates = subparsers.add_parser(
|
|
2136
|
+
"find-candidates",
|
|
2137
|
+
help="Find existing entries that a correction should rewrite/delete before appending",
|
|
2138
|
+
)
|
|
2139
|
+
append_kinds = [name for name, spec in sorted(KIND_MAP.items()) if spec.get("default_mode") == "append"]
|
|
2140
|
+
_add_common_args(find_candidates)
|
|
2141
|
+
find_candidates.add_argument("--query", help="Inline text describing the old entry to find")
|
|
2142
|
+
find_candidates.add_argument("--query-file", help="File containing search text")
|
|
2143
|
+
find_candidates.add_argument("--kind", help=f"Restrict search to one append kind. One of: {', '.join(append_kinds)}")
|
|
2144
|
+
find_candidates.add_argument("--limit", type=int, default=8, help="Maximum number of candidates to return")
|
|
2145
|
+
find_candidates.add_argument("--min-score", type=float, default=0.2, help="Minimum fuzzy score in [0.0, 1.0]")
|
|
2146
|
+
|
|
1816
2147
|
record = subparsers.add_parser("record", help="Write content into one or more sections")
|
|
1817
2148
|
_add_common_args(record)
|
|
1818
2149
|
record.add_argument("--kind", help=f"Explicit kind. One of: {', '.join(sorted(KIND_MAP.keys()))}")
|
|
@@ -1879,6 +2210,8 @@ def main():
|
|
|
1879
2210
|
"show": command_show,
|
|
1880
2211
|
"suggest-kind": command_suggest_kind,
|
|
1881
2212
|
"classify": command_classify,
|
|
2213
|
+
"list-entries": command_list_entries,
|
|
2214
|
+
"find-candidates": command_find_candidates,
|
|
1882
2215
|
"record": command_record,
|
|
1883
2216
|
"rewrite-entry": command_rewrite_entry,
|
|
1884
2217
|
"delete-entry": command_delete_entry,
|
package/package.json
CHANGED
package/scripts/hooks/_common.py
CHANGED
|
@@ -41,6 +41,7 @@ CONTEXT_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_context.py"
|
|
|
41
41
|
CAPTURE_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_capture.py"
|
|
42
42
|
SUMMARY_WORKER_SCRIPT = PACKAGE_ROOT / "scripts" / "hooks" / "session_summary_worker.py"
|
|
43
43
|
DEFAULT_CONFIG_PATH = Path(os.environ.get("DEV_MEMORY_CONFIG_PATH", "~/.dev-memory/config.json")).expanduser()
|
|
44
|
+
WORKSPACE_CONFIG_NAMES = (".dev-memory-workspace.json", ".dev-assets-workspace.json")
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def run_python(script_path, *args, cwd=None):
|
|
@@ -153,12 +154,30 @@ def list_workspace_repos():
|
|
|
153
154
|
|
|
154
155
|
|
|
155
156
|
def primary_repo_name():
|
|
156
|
-
"""Basename of the focus repo
|
|
157
|
+
"""Basename of the focus repo. Env is a one-off override; otherwise use
|
|
158
|
+
workspace-local config so different model workspaces can choose different
|
|
159
|
+
primary repos under the same global hook install.
|
|
160
|
+
"""
|
|
157
161
|
value = (
|
|
158
162
|
os.environ.get("DEV_MEMORY_PRIMARY_REPO", "").strip()
|
|
159
163
|
or os.environ.get("DEV_ASSETS_PRIMARY_REPO", "").strip()
|
|
160
164
|
)
|
|
161
|
-
|
|
165
|
+
if value:
|
|
166
|
+
return value
|
|
167
|
+
for name in WORKSPACE_CONFIG_NAMES:
|
|
168
|
+
config_path = REPO_ROOT / name
|
|
169
|
+
try:
|
|
170
|
+
if not config_path.exists():
|
|
171
|
+
continue
|
|
172
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
continue
|
|
175
|
+
configured = data.get("primary_repo") or data.get("primaryRepo")
|
|
176
|
+
if isinstance(configured, str) and configured.strip():
|
|
177
|
+
return configured.strip()
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
log(f"[dev-memory] workspace config ignored ({config_path}): {exc}")
|
|
180
|
+
return None
|
|
162
181
|
|
|
163
182
|
|
|
164
183
|
def strip_managed_markers(text):
|
|
@@ -341,27 +360,21 @@ def maybe_record_head():
|
|
|
341
360
|
# "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
|
|
342
361
|
# "当前有效上下文".
|
|
343
362
|
_FULL_SECTION_KEYS = (
|
|
344
|
-
("glossary", "分支源资料入口"),
|
|
345
|
-
("glossary", "当前有效上下文"),
|
|
346
363
|
("progress", "功能文件索引"),
|
|
347
364
|
("progress", "建议优先查看的目录"),
|
|
365
|
+
("repo_decisions", None),
|
|
366
|
+
("repo_glossary", None),
|
|
367
|
+
("glossary", "分支源资料入口"),
|
|
348
368
|
("overview", "当前目标"),
|
|
349
369
|
("overview", "范围边界"),
|
|
350
370
|
("overview", "关键约束"),
|
|
371
|
+
("repo_overview", None),
|
|
372
|
+
("glossary", "当前有效上下文"),
|
|
351
373
|
("risks", "阻塞与注意点"),
|
|
352
374
|
("decisions", "关键决策与原因"),
|
|
353
375
|
("risks", "后续继续前要注意"),
|
|
354
|
-
("repo_overview", None),
|
|
355
|
-
("repo_decisions", None),
|
|
356
|
-
("repo_glossary", None),
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
_BRIEF_SECTION_KEYS = (
|
|
360
|
-
("overview", "当前目标"),
|
|
361
|
-
("glossary", "当前有效上下文"),
|
|
362
376
|
)
|
|
363
377
|
|
|
364
|
-
|
|
365
378
|
_RECENT_FIRST_SECTIONS = {
|
|
366
379
|
("decisions", "关键决策与原因"),
|
|
367
380
|
("risks", "阻塞与注意点"),
|
|
@@ -389,9 +402,46 @@ _REPO_DISPLAY_TITLES = {
|
|
|
389
402
|
"repo_decisions": "跨分支通用决策",
|
|
390
403
|
"repo_glossary": "仓库共享术语与入口",
|
|
391
404
|
}
|
|
405
|
+
_HIGH_PRIORITY_WRAPPERS = {
|
|
406
|
+
("progress", "功能文件索引"): "file_map",
|
|
407
|
+
("progress", "建议优先查看的目录"): "read_first_dirs",
|
|
408
|
+
("repo_decisions", None): "shared_decisions",
|
|
409
|
+
("repo_glossary", None): "long_term_context",
|
|
410
|
+
}
|
|
392
411
|
_REPO_MAX_LINES = 32
|
|
393
412
|
_REPO_MAX_CHARS = 3000
|
|
394
413
|
|
|
414
|
+
_BRIEF_PROFILES = {
|
|
415
|
+
"expanded": {
|
|
416
|
+
("progress", "功能文件索引"): (128, 12000, False),
|
|
417
|
+
("progress", "建议优先查看的目录"): (128, 12000, False),
|
|
418
|
+
("repo_decisions", None): (32, 3000, True),
|
|
419
|
+
("repo_glossary", None): (32, 3000, True),
|
|
420
|
+
},
|
|
421
|
+
"standard": {
|
|
422
|
+
("progress", "功能文件索引"): (16, 1600, True),
|
|
423
|
+
("progress", "建议优先查看的目录"): (8, 800, False),
|
|
424
|
+
("repo_decisions", None): (8, 900, True),
|
|
425
|
+
("repo_glossary", None): (8, 900, True),
|
|
426
|
+
},
|
|
427
|
+
"minimal": {
|
|
428
|
+
("progress", "功能文件索引"): (5, 600, True),
|
|
429
|
+
("progress", "建议优先查看的目录"): (5, 600, False),
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def brief_profile_for_repo_count(repo_count):
|
|
435
|
+
if repo_count <= 2:
|
|
436
|
+
return "expanded"
|
|
437
|
+
if repo_count <= 5:
|
|
438
|
+
return "standard"
|
|
439
|
+
return "minimal"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _brief_section_keys(profile):
|
|
443
|
+
return tuple(_BRIEF_PROFILES.get(profile, _BRIEF_PROFILES["standard"]).keys())
|
|
444
|
+
|
|
395
445
|
|
|
396
446
|
def _extract_sections(paths, keys):
|
|
397
447
|
out = []
|
|
@@ -402,11 +452,12 @@ def _extract_sections(paths, keys):
|
|
|
402
452
|
else:
|
|
403
453
|
body = extract_section(paths[file_key], title)
|
|
404
454
|
display_title = title
|
|
405
|
-
|
|
455
|
+
wrapper = _HIGH_PRIORITY_WRAPPERS.get((file_key, title))
|
|
456
|
+
out.append((display_title, body, file_key, title, wrapper))
|
|
406
457
|
return out
|
|
407
458
|
|
|
408
459
|
|
|
409
|
-
def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
460
|
+
def _build_context_from_assets(assets, *, full=True, heading=None, brief_profile="standard"):
|
|
410
461
|
if not assets["branch_dir"].exists():
|
|
411
462
|
# v2: capture lazy-inits on first write, so branch_dir typically
|
|
412
463
|
# exists after any real interaction. Missing here just means no
|
|
@@ -419,9 +470,10 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
|
419
470
|
return None
|
|
420
471
|
|
|
421
472
|
paths = assets["paths"]
|
|
422
|
-
keys = _FULL_SECTION_KEYS if full else
|
|
473
|
+
keys = _FULL_SECTION_KEYS if full else _brief_section_keys(brief_profile)
|
|
423
474
|
sections = _extract_sections(paths, keys)
|
|
424
475
|
max_lines, max_chars = (8, 700) if full else (3, 200)
|
|
476
|
+
brief_limits = _BRIEF_PROFILES.get(brief_profile, _BRIEF_PROFILES["standard"])
|
|
425
477
|
|
|
426
478
|
parts = []
|
|
427
479
|
no_git = assets.get("branch_name") is None
|
|
@@ -437,20 +489,27 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
|
437
489
|
# the footer's directory header (.../branches/<branch>/).
|
|
438
490
|
|
|
439
491
|
any_truncated = False
|
|
440
|
-
for title, body, file_key in sections:
|
|
492
|
+
for title, body, file_key, source_title, wrapper in sections:
|
|
441
493
|
if not body:
|
|
442
494
|
continue
|
|
443
|
-
if full and file_key in
|
|
495
|
+
if not full and (file_key, source_title) in brief_limits:
|
|
496
|
+
eff_lines, eff_chars, prefer_recent = brief_limits[(file_key, source_title)]
|
|
497
|
+
elif full and file_key in _REPO_LEVEL_KEYS:
|
|
444
498
|
eff_lines, eff_chars = _REPO_MAX_LINES, _REPO_MAX_CHARS
|
|
445
|
-
|
|
499
|
+
prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
|
|
500
|
+
elif full and (file_key, source_title) in _BRANCH_REFERENCE_SECTIONS:
|
|
446
501
|
eff_lines, eff_chars = _BRANCH_REF_MAX_LINES, _BRANCH_REF_MAX_CHARS
|
|
502
|
+
prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
|
|
447
503
|
else:
|
|
448
504
|
eff_lines, eff_chars = max_lines, max_chars
|
|
449
|
-
|
|
505
|
+
prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
|
|
506
|
+
if prefer_recent:
|
|
450
507
|
compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
451
508
|
else:
|
|
452
509
|
compacted, truncated = compact_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
453
|
-
if
|
|
510
|
+
if wrapper:
|
|
511
|
+
block = f"<{wrapper}>\n{compacted}\n</{wrapper}>"
|
|
512
|
+
elif full and file_key in _REPO_LEVEL_KEYS:
|
|
454
513
|
block = compacted
|
|
455
514
|
else:
|
|
456
515
|
block = f"{title}:\n{compacted}"
|
|
@@ -545,7 +604,7 @@ def build_session_start_context():
|
|
|
545
604
|
return _build_context_from_assets(assets, full=True)
|
|
546
605
|
|
|
547
606
|
|
|
548
|
-
def build_context_for_repo(repo_path, *, full=True, is_primary=False):
|
|
607
|
+
def build_context_for_repo(repo_path, *, full=True, is_primary=False, brief_profile="standard"):
|
|
549
608
|
"""Build a per-repo context block for workspace-mode SessionStart injection.
|
|
550
609
|
Returns None when the repo has no initialized branch memory or resolution fails.
|
|
551
610
|
"""
|
|
@@ -562,14 +621,15 @@ def build_context_for_repo(repo_path, *, full=True, is_primary=False):
|
|
|
562
621
|
heading = (
|
|
563
622
|
f"## {tag}`{Path(repo_path).name}` @ branch `{assets['branch_name']}`"
|
|
564
623
|
)
|
|
565
|
-
return _build_context_from_assets(assets, full=full, heading=heading)
|
|
624
|
+
return _build_context_from_assets(assets, full=full, heading=heading, brief_profile=brief_profile)
|
|
566
625
|
|
|
567
626
|
|
|
568
627
|
def build_workspace_start_context():
|
|
569
628
|
"""SessionStart context for workspace mode. Primary repo gets full memory;
|
|
570
|
-
others get a brief
|
|
629
|
+
others get a dynamically compacted brief context. Returns None if no
|
|
630
|
+
initialized repos.
|
|
571
631
|
|
|
572
|
-
Fallback when
|
|
632
|
+
Fallback when no env/workspace-local primary is set:
|
|
573
633
|
- Single-repo workspace → that repo is full (user's intent is obvious).
|
|
574
634
|
- Multi-repo workspace → all brief, so N full dumps can't drown the
|
|
575
635
|
session. Header tells the agent how to promote one to full.
|
|
@@ -579,6 +639,7 @@ def build_workspace_start_context():
|
|
|
579
639
|
return None
|
|
580
640
|
primary = primary_repo_name()
|
|
581
641
|
only_one_repo = len(repos) == 1
|
|
642
|
+
brief_profile = brief_profile_for_repo_count(len(repos))
|
|
582
643
|
primary_hit = False
|
|
583
644
|
has_brief = False
|
|
584
645
|
sections = []
|
|
@@ -591,7 +652,12 @@ def build_workspace_start_context():
|
|
|
591
652
|
primary_hit = True
|
|
592
653
|
else:
|
|
593
654
|
has_brief = True
|
|
594
|
-
ctx = build_context_for_repo(
|
|
655
|
+
ctx = build_context_for_repo(
|
|
656
|
+
repo_path,
|
|
657
|
+
full=is_primary,
|
|
658
|
+
is_primary=is_primary,
|
|
659
|
+
brief_profile=brief_profile,
|
|
660
|
+
)
|
|
595
661
|
if ctx:
|
|
596
662
|
sections.append(ctx)
|
|
597
663
|
if not sections:
|
|
@@ -604,7 +670,7 @@ def build_workspace_start_context():
|
|
|
604
670
|
header_parts.append(f"Primary 仓库:`{primary}` ({status})")
|
|
605
671
|
if has_brief:
|
|
606
672
|
header_parts.append(
|
|
607
|
-
"_其它仓库按 brief 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
|
|
673
|
+
f"_其它仓库按 brief/{brief_profile} 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
|
|
608
674
|
"聚焦时直接 Read 即可(如需 CLI:`dev-memory-cli context show --repo <name>`)。_"
|
|
609
675
|
)
|
|
610
676
|
header = "\n".join(header_parts)
|
|
@@ -881,6 +947,14 @@ transcript 过滤(extract-core 已执行;这里是核对规则):
|
|
|
881
947
|
- 只在确有新增或更新时写入。没有有效新增时只输出 `skip_reason`,代码会把 job 标记为 skipped。
|
|
882
948
|
- 不要写”当前进展”、”下一步”、”当前阶段”等时效性状态——这些天然会过期;记忆应聚焦稳定的决策、约束、上下文和参考信息。
|
|
883
949
|
|
|
950
|
+
repo 共享层淘汰规则:
|
|
951
|
+
- `shared_decisions` / `shared_context` / `shared_sources` 是跨分支长期记忆,不是会话归档,也不是分支任务备忘。
|
|
952
|
+
- 只有“以后所有分支都需要优先知道”的规则、背景、入口才写 shared;普通业务细节、一次性调试结论、某分支当前状态写 branch 层或跳过。
|
|
953
|
+
- repo 共享层默认只保留最重要的少量条目(约 10-20 条);新增 shared 前先看 existing_memory,若旧条目已过时、重复、太长,优先输出 `rewrites` / `deletes` 压缩旧内容。
|
|
954
|
+
- `shared_decisions` 的 summary 必须是一条短完整规则;除非必要,不要填 reason/impact。不要把“结论/原因/影响范围”拆成三条长期条目。
|
|
955
|
+
- `shared_context` / `shared_sources` 每条尽量不超过 1 句;不要把交接说明、模块清单、会议纪要整段塞入 repo 共享层。
|
|
956
|
+
- 代码落盘后还会自动对 repo shared section 做确定性修剪:保留最近约 20 条,单条过长会压成首句。因此你应主动写得精炼,避免重要信息被截断。
|
|
957
|
+
|
|
884
958
|
file_map(功能文件索引):
|
|
885
959
|
- 用途:让后续 agent 听到”改下操作弹窗”就能直接定位文件,不用搜索。
|
|
886
960
|
- 每条 label 是业务语义名(页面/弹窗/侧拉/组件/表单等),paths 是对应文件的仓库相对路径。
|
|
@@ -898,9 +972,9 @@ file_map(功能文件索引):
|
|
|
898
972
|
“decisions”: [{{“summary”: “结论”, “reason”: “为什么”, “impact”: “影响范围”}}],
|
|
899
973
|
“risks”: [“风险/坑/阻塞”],
|
|
900
974
|
“glossary”: [“术语/上下文/命令/外部系统入口”],
|
|
901
|
-
“shared_decisions”: [{{“summary”:
|
|
902
|
-
“shared_context”: [
|
|
903
|
-
“shared_sources”: [
|
|
975
|
+
“shared_decisions”: [{{“summary”: “短句跨分支规则”}}],
|
|
976
|
+
“shared_context”: [“一句话仓库级长期背景”],
|
|
977
|
+
“shared_sources”: [“一句话仓库级共享入口”],
|
|
904
978
|
“upserts”: [{{“kind”: “overview”, “content”: “覆盖某个 kind”}}],
|
|
905
979
|
“appends”: [{{“kind”: “decision”, “content”: “追加某个 kind”}}],
|
|
906
980
|
“rewrites”: [{{“id”: “decisions::0::2”, “content”: “新条目”, “reason”: “旧结论失效”}}],
|