dev-memory-cli 0.20.0 → 0.22.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
 
@@ -449,6 +449,121 @@ def _check_dedup_for_kind(paths, kind, body, force=False, title_override=None, t
449
449
  return _build_dedup_hint(kind, file_key, section_title, body, matches)
450
450
 
451
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
+
452
567
  # ---------------------------------------------------------------------------
453
568
  # rewrite-entry primitives
454
569
  # ---------------------------------------------------------------------------
@@ -1211,6 +1326,68 @@ def command_classify(args):
1211
1326
  return 0
1212
1327
 
1213
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
+
1214
1391
  def command_record(args):
1215
1392
  repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1216
1393
  args.repo, args.context_dir, args.branch
@@ -1946,6 +2123,27 @@ def main():
1946
2123
  classify.add_argument("--content", help="Inline content to classify")
1947
2124
  classify.add_argument("--content-file", help="File containing content to classify")
1948
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
+
1949
2147
  record = subparsers.add_parser("record", help="Write content into one or more sections")
1950
2148
  _add_common_args(record)
1951
2149
  record.add_argument("--kind", help=f"Explicit kind. One of: {', '.join(sorted(KIND_MAP.keys()))}")
@@ -2012,6 +2210,8 @@ def main():
2012
2210
  "show": command_show,
2013
2211
  "suggest-kind": command_suggest_kind,
2014
2212
  "classify": command_classify,
2213
+ "list-entries": command_list_entries,
2214
+ "find-candidates": command_find_candidates,
2015
2215
  "record": command_record,
2016
2216
  "rewrite-entry": command_rewrite_entry,
2017
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.20.0",
3
+ "version": "0.22.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):
@@ -356,12 +375,6 @@ _FULL_SECTION_KEYS = (
356
375
  ("risks", "后续继续前要注意"),
357
376
  )
358
377
 
359
- _BRIEF_SECTION_KEYS = (
360
- ("overview", "当前目标"),
361
- ("glossary", "当前有效上下文"),
362
- )
363
-
364
-
365
378
  _RECENT_FIRST_SECTIONS = {
366
379
  ("decisions", "关键决策与原因"),
367
380
  ("risks", "阻塞与注意点"),
@@ -398,6 +411,37 @@ _HIGH_PRIORITY_WRAPPERS = {
398
411
  _REPO_MAX_LINES = 32
399
412
  _REPO_MAX_CHARS = 3000
400
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
+
401
445
 
402
446
  def _extract_sections(paths, keys):
403
447
  out = []
@@ -413,7 +457,7 @@ def _extract_sections(paths, keys):
413
457
  return out
414
458
 
415
459
 
416
- def _build_context_from_assets(assets, *, full=True, heading=None):
460
+ def _build_context_from_assets(assets, *, full=True, heading=None, brief_profile="standard"):
417
461
  if not assets["branch_dir"].exists():
418
462
  # v2: capture lazy-inits on first write, so branch_dir typically
419
463
  # exists after any real interaction. Missing here just means no
@@ -426,9 +470,10 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
426
470
  return None
427
471
 
428
472
  paths = assets["paths"]
429
- keys = _FULL_SECTION_KEYS if full else _BRIEF_SECTION_KEYS
473
+ keys = _FULL_SECTION_KEYS if full else _brief_section_keys(brief_profile)
430
474
  sections = _extract_sections(paths, keys)
431
475
  max_lines, max_chars = (8, 700) if full else (3, 200)
476
+ brief_limits = _BRIEF_PROFILES.get(brief_profile, _BRIEF_PROFILES["standard"])
432
477
 
433
478
  parts = []
434
479
  no_git = assets.get("branch_name") is None
@@ -447,13 +492,18 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
447
492
  for title, body, file_key, source_title, wrapper in sections:
448
493
  if not body:
449
494
  continue
450
- 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:
451
498
  eff_lines, eff_chars = _REPO_MAX_LINES, _REPO_MAX_CHARS
499
+ prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
452
500
  elif full and (file_key, source_title) in _BRANCH_REFERENCE_SECTIONS:
453
501
  eff_lines, eff_chars = _BRANCH_REF_MAX_LINES, _BRANCH_REF_MAX_CHARS
502
+ prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
454
503
  else:
455
504
  eff_lines, eff_chars = max_lines, max_chars
456
- if (file_key, source_title) in _RECENT_FIRST_SECTIONS:
505
+ prefer_recent = (file_key, source_title) in _RECENT_FIRST_SECTIONS
506
+ if prefer_recent:
457
507
  compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
458
508
  else:
459
509
  compacted, truncated = compact_body(body, max_lines=eff_lines, max_chars=eff_chars)
@@ -554,7 +604,7 @@ def build_session_start_context():
554
604
  return _build_context_from_assets(assets, full=True)
555
605
 
556
606
 
557
- 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"):
558
608
  """Build a per-repo context block for workspace-mode SessionStart injection.
559
609
  Returns None when the repo has no initialized branch memory or resolution fails.
560
610
  """
@@ -571,14 +621,15 @@ def build_context_for_repo(repo_path, *, full=True, is_primary=False):
571
621
  heading = (
572
622
  f"## {tag}`{Path(repo_path).name}` @ branch `{assets['branch_name']}`"
573
623
  )
574
- 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)
575
625
 
576
626
 
577
627
  def build_workspace_start_context():
578
628
  """SessionStart context for workspace mode. Primary repo gets full memory;
579
- 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.
580
631
 
581
- Fallback when DEV_MEMORY_PRIMARY_REPO is unset:
632
+ Fallback when no env/workspace-local primary is set:
582
633
  - Single-repo workspace → that repo is full (user's intent is obvious).
583
634
  - Multi-repo workspace → all brief, so N full dumps can't drown the
584
635
  session. Header tells the agent how to promote one to full.
@@ -588,6 +639,7 @@ def build_workspace_start_context():
588
639
  return None
589
640
  primary = primary_repo_name()
590
641
  only_one_repo = len(repos) == 1
642
+ brief_profile = brief_profile_for_repo_count(len(repos))
591
643
  primary_hit = False
592
644
  has_brief = False
593
645
  sections = []
@@ -600,7 +652,12 @@ def build_workspace_start_context():
600
652
  primary_hit = True
601
653
  else:
602
654
  has_brief = True
603
- 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
+ )
604
661
  if ctx:
605
662
  sections.append(ctx)
606
663
  if not sections:
@@ -613,7 +670,7 @@ def build_workspace_start_context():
613
670
  header_parts.append(f"Primary 仓库:`{primary}` ({status})")
614
671
  if has_brief:
615
672
  header_parts.append(
616
- "_其它仓库按 brief 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
673
+ f"_其它仓库按 brief/{brief_profile} 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
617
674
  "聚焦时直接 Read 即可(如需 CLI:`dev-memory-cli context show --repo <name>`)。_"
618
675
  )
619
676
  header = "\n".join(header_parts)