dev-memory-cli 0.20.0 → 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 +200 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +75 -18
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
|
|
|
@@ -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
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):
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
629
|
+
others get a dynamically compacted brief context. Returns None if no
|
|
630
|
+
initialized repos.
|
|
580
631
|
|
|
581
|
-
Fallback when
|
|
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(
|
|
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)
|