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 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 仓库注入完整记忆 + 其它仓库简短概览;Stop/PreCompact/SessionEnd 对每个仓库各记一次 HEAD;skill 通过 `--repo <basename>` 明确目标仓库,或读 `DEV_ASSETS_PRIMARY_REPO` 作为默认 |
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` 接受**仓库目录 basename**(不是绝对路径)。
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 = [decision_body(item) for item in shared_decision_payload_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", _decision_content(item), source="shared_decisions")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 from env; None if unset."""
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
- return value or None
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
- out.append((display_title, body, file_key))
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 _BRIEF_SECTION_KEYS
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 _REPO_LEVEL_KEYS:
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
- elif full and (file_key, title) in _BRANCH_REFERENCE_SECTIONS:
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
- if (file_key, title) in _RECENT_FIRST_SECTIONS:
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 full and file_key in _REPO_LEVEL_KEYS:
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 overview only. Returns None if no initialized repos.
629
+ others get a dynamically compacted brief context. Returns None if no
630
+ initialized repos.
571
631
 
572
- Fallback when DEV_MEMORY_PRIMARY_REPO is unset:
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(repo_path, full=is_primary, is_primary=is_primary)
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”: “跨分支规则”, “reason”: “为什么”, “impact”: “适用范围”}}],
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”: “旧结论失效”}}],