@fenglimg/fabric-cli 2.2.0-rc.8 → 2.2.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.
@@ -75,11 +75,9 @@ const {
75
75
  } = require("node:fs");
76
76
  const { dirname, join } = require("node:path");
77
77
 
78
- // rc.35 TASK-06 (P0-10.b): summary-fallback. Substitutes opaque entries
79
- // (where description.summary === stable_id) with a snippet read from the
80
- // entry's .md `## Summary` section. Caches results in
81
- // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
82
- const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
78
+ // KT-GLD-0006: the rc.35 opaque-summary substitution (resolveOpaqueSummaries) is
79
+ // retired the write-time mechanical floor in extractKnowledge prevents
80
+ // degenerate summaries at the source, so the narrow hook no longer band-aids them.
83
81
  // v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
84
82
  // cache (skips a redundant CLI cold-start spawn when the same path-set is
85
83
  // re-edited within a session and the knowledge graph hasn't changed).
@@ -1492,21 +1490,10 @@ async function main(env, stdio) {
1492
1490
  }
1493
1491
 
1494
1492
  const summaryMaxLen = readSummaryMaxLen(cwd);
1495
- // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1496
- // Same lib used by the broad hook — opaque entries seen from both call
1497
- // sites share a single .fabric/.cache/summary-fallback.json file.
1498
- // Best-effort any failure leaves the original opaque summary intact.
1499
- let resolvedEntries = dedupDecision.filtered;
1500
- try {
1501
- resolvedEntries = resolveOpaqueSummaries(
1502
- dedupDecision.filtered,
1503
- cwd,
1504
- currentRevisionHash,
1505
- );
1506
- } catch {
1507
- // resolveOpaqueSummaries swallows its own errors; defensive catch.
1508
- }
1509
- const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
1493
+ // KT-GLD-0006: the rc.35 opaque-summary runtime substitution is retired the
1494
+ // write-time mechanical floor in extractKnowledge prevents degenerate summaries
1495
+ // at the source, so the narrow hook renders the description summary as-is.
1496
+ const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
1510
1497
  if (lines.length === 0) return;
1511
1498
 
1512
1499
  // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
@@ -1564,7 +1551,7 @@ async function main(env, stdio) {
1564
1551
  const surfaceClient = detectClient();
1565
1552
  const fabricDir = join(cwd, FABRIC_DIR_REL);
1566
1553
  if (surfaceClient !== undefined && existsSync(fabricDir)) {
1567
- const renderedIds = resolvedEntries
1554
+ const renderedIds = dedupDecision.filtered
1568
1555
  .map((e) => (e && typeof e.id === "string" ? e.id : null))
1569
1556
  .filter((x) => x !== null);
1570
1557
  const realSessionId =
@@ -28,8 +28,9 @@
28
28
  *
29
29
  * - STRINGS — exported for test introspection only (read-only by convention).
30
30
  *
31
- * Banner keys (11 total):
31
+ * Banner keys:
32
32
  * Signal A (archive): archiveLine1, archiveActivity, archiveCta
33
+ * Archive backlog: backlogLine1, backlogCta
33
34
  * Signal B (review): reviewLine1, reviewCta
34
35
  * Signal C (import): importLine1, importCta
35
36
  * Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
@@ -165,6 +166,23 @@ const STRINGS = {
165
166
  "zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
166
167
  },
167
168
 
169
+ // ---- Archive backlog (cross-session safety net, crack 2) ------------------
170
+ // Replaces the old global-24h archive timer: counts DEAD sessions (session
171
+ // ended / idle) carrying unarchived high-value work. Substring "${count}" is
172
+ // addressable for tests. params: { count: number }
173
+ backlogLine1: {
174
+ "zh-CN": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
175
+ en: (p) => `📋 Fabric: ${p.count} ended session(s) carry unarchived high-value work.`,
176
+ "zh-CN-hybrid": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
177
+ },
178
+
179
+ // params: {} — protected token /fabric-archive verbatim across all variants.
180
+ backlogCta: {
181
+ "zh-CN": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
182
+ en: () => " Run /fabric-archive to sweep these missed sessions across the backlog?",
183
+ "zh-CN-hybrid": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
184
+ },
185
+
168
186
  // ---- Signal B: review -----------------------------------------------------
169
187
  // Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
170
188
  // params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
@@ -148,18 +148,19 @@ function liveKnowledgeStats(snapshot) {
148
148
  }
149
149
  return { pendingCount, canonicalCount, oldestPendingMtimeMs };
150
150
  }
151
- // Backward-compatible fallback: snapshot predates knowledge_store_dirs.
152
- const stats = snapshot.knowledge_stats;
153
- if (stats && typeof stats === "object") {
154
- return {
155
- pendingCount: Number.isFinite(stats.pending_count) ? Math.floor(stats.pending_count) : 0,
156
- canonicalCount: Number.isFinite(stats.canonical_count) ? Math.floor(stats.canonical_count) : 0,
157
- oldestPendingMtimeMs:
158
- Number.isFinite(stats.oldest_pending_mtime_ms) && stats.oldest_pending_mtime_ms > 0
159
- ? stats.oldest_pending_mtime_ms
160
- : null,
161
- };
162
- }
151
+ // #3 (GH issue): snapshot predates knowledge_store_dirs. The cached
152
+ // `knowledge_stats` projection is frozen at snapshot-write time and goes stale
153
+ // out-of-band (store grew via git pull / cross-workspace sync), so trusting it
154
+ // re-introduced exactly the false-nudge this whole field cures — observed a
155
+ // store with 61 live canonical entries whose cached count was frozen at 1,
156
+ // mis-firing the "knowledge sparse → /fabric-import" underseed nudge AND
157
+ // defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
158
+ // no resolved store root either (alias/uuid only), so a live recount is
159
+ // impossible without re-resolution (which hooks must not do). Return null
160
+ // ("undeterminable") so callers SKIP the nudge rather than act on a stale
161
+ // count — old snapshots self-heal on the next install/sync/store-op (which
162
+ // regenerates the snapshot WITH knowledge_store_dirs). 宁可不弹也别误弹
163
+ // (KT-DEC-0007: hook = nudge, never a false-positive gate).
163
164
  return null;
164
165
  }
165
166
 
@@ -17,15 +17,21 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
17
17
 
18
18
  ## Intent Map
19
19
 
20
- | 用户意图 | 下游 skill |
20
+ <!-- fabric:router-intent:begin -->
21
+ <!-- 本块由 `fabric install` 从 7 个 leaf skill 的 description Triggers 子句生成。严禁手编;改 leaf description 后重跑 `fabric install`。 -->
22
+
23
+ | 用户意图(leaf description Triggers) | 下游 skill |
21
24
  | --- | --- |
22
- | 记录/归档/以后记住/always/never/下次注意 | `fabric-archive` |
23
- | 审批 pending、批量 approve/reject/modify/revisit/defer | `fabric-review` |
24
- | git log、docs 或历史材料冷启动导入知识 | `fabric-import` |
25
- | 创建、挂载、绑定、列出、切换 write store | `fabric-store` |
26
- | store pull --rebase + push、同步冲突处理 | `fabric-sync` |
27
- | 知识库体检、淘汰陈旧条目、deprecate、rescue-before-delete | `fabric-audit` |
28
- | 发现 KB 条目关联、补 `related` 边、知识图谱连通性 | `fabric-connect` |
25
+ | 以后/always/never/下次/记一下;wrong-turn-revert;decision-confirm;dismissal-reason;/fabric-archive | `fabric-archive` |
26
+ | 审批/驳回/复审/重审/approve/reject/review pending | `fabric-review` |
27
+ | 导入历史/bootstrap fabric/mine changelog/挖掘 commit | `fabric-import` |
28
+ | 同步知识库/sync stores/fabric-sync/解决 store 冲突/rebase 冲突 | `fabric-sync` |
29
+ | 创建 store/挂载 store/绑定知识库/store 列表/切换写库/set up knowledge store | `fabric-store` |
30
+ | 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策 | `fabric-audit` |
31
+ | 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性 | `fabric-connect` |
32
+
33
+ `S_CLASSIFY` 的 `task_type` 枚举:`archive | review | import | sync | store | audit | connect`
34
+ <!-- fabric:router-intent:end -->
29
35
 
30
36
  ## State Machine
31
37
 
@@ -35,7 +41,7 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
35
41
 
36
42
  ```json
37
43
  {
38
- "task_type": "archive|review|import|store|sync|audit|connect",
44
+ "task_type": "<Intent Map task_type 枚举之一>",
39
45
  "scope": "project|store|entry|paths|null",
40
46
  "write_intent": true,
41
47
  "confidence": "high|medium|low"
@@ -65,6 +65,18 @@ The deterministic ledger scan now runs **server-side** — call `fab_archive_sca
65
65
 
66
66
  Then (LLM side, Boundary B): for each returned `session_id`, load `.fabric/.cache/session-digests/<session_id>.md`, concatenate into a `### Cross-session digest` block, and populate `source_sessions[]` + `session_context` for Phase 4. Cap at `archive_digest_max_sessions`. Missing digest files degrade silently.
67
67
 
68
+ **Coverage transparency (crack 3 — cheap recall backstop).** BEFORE collecting candidates, surface the scan's watermark + drops to the user so a human can act as the recall detector and manually override (`--range <session_id>` to force a dropped session back in). This is the affordable substitute for the (deferred) periodic cold-eval miss-rate audit — show, don't hide, what the deterministic filter skipped:
69
+
70
+ ```
71
+ 📋 归档覆盖到 <covered_through_ts 转人类可读时间>。
72
+ 纳入会话: <session_ids.length> 个。
73
+ 跳过 <dropped.length> 个: <每个 {session_id 短码} (reason)>
74
+ reason 含义: user_dismissed=用户曾拒绝 / cooldown=12h 防抖内 / no_new_signal=自上次归档无新高价值活
75
+ 若某个被跳过的会话其实有该归档的内容,显式 `fabric-archive --range <session_id>` 强制纳入。
76
+ ```
77
+
78
+ Render `dropped` only when non-empty; render the watermark line always. en variant mirrors the same fields. Keep it ONE compact block — this is a backstop affordance, not a report.
79
+
68
80
  `Read ref/phase-1-cross-session.md` for the filter state machine + digest-stitch + graceful-degradation notes. The hand-rolled `tail -n 200` scan is retired — `fab_archive_scan` is the source of truth.
69
81
 
70
82
  Graceful degradation: missing digest cache → single-session fallback. Missing `session_archive_attempted` events (pre-rc.25) → legacy "scan everything since anchor" behaviour.
@@ -93,6 +105,8 @@ Pre-PASS HARD gate (rc.37 NEW-4): per candidate, run `fab_review action="search"
93
105
 
94
106
  For each candidate, propose **type** ∈ {model, decision, guideline, pitfall, process}, **layer** ∈ {team, personal} via the verbatim heuristic below, **slug** (kebab-case 2-5 words, 20-40 chars, unique within type+layer bucket), **summary** (1-2 sentences).
95
107
 
108
+ > **Self-sufficiency standard — guideline / model summaries (KT-GLD-0001/0006).** These two types land in the SessionStart **ALWAYS-ACTIVE** sink as a single INDEX line with NO body injected — so the summary IS the operative rule the agent acts on. Author it as a self-contained imperative that states the thesis (the *what* + the operative *so-what*), e.g. `改源码前先读 bootstrap+compiler config;scripts 为 init 主执行边界`. A topic label that only points at the body (`Code style guidelines`, `Scope model`) is NOT acceptable here — the reader can't act on it without a fetch, breaking the always-active contract. decision/pitfall/process summaries are exempt (they surface as `must_read_if` triggers, deliberately pointers). Do NOT self-judge sufficiency in this phase (curse-of-knowledge rubber-stamps — KT-GLD-0006); authoring to the standard is the write-time floor, the zero-context cold-eval at review time is the real gate.
109
+
96
110
  #### Layer Classification Heuristic (verbatim, contract-locked)
97
111
 
98
112
  > - **强 team**: 引用本项目代码、团队共识用语("we decided")、fabric-import 路径产物、业务领域、绑定本项目代码的 pitfall
@@ -119,6 +119,15 @@ DO NOT AskUserQuestion "is this a duplicate?" — LLM already judged. User only
119
119
 
120
120
  `Read ref/semantic-check.md` for full procedure + 三类判断的细化定义.
121
121
 
122
+ ## Summary Self-Sufficiency Gate (guideline / model only — KT-GLD-0006)
123
+
124
+ Guideline/model entries surface in the SessionStart **ALWAYS-ACTIVE** sink as a body-less INDEX line, so an opaque summary (`Code style guidelines`) leaks in as an unactionable "rule". Before approving/promoting a **guideline or model** (only these two types — decision/pitfall/process surface as `must_read_if` triggers and are exempt), run the summary through the **zero-context cold-eval judge**, never your own judgment:
125
+
126
+ - The reviewing agent self-judging sufficiency is curse-of-knowledge — it back-fills from context it already has and rubber-stamps pointers (KT-GLD-0006). The withheld-body cold eval is the whole point.
127
+ - Build the batch with `summary-cold-eval.ts#buildColdEvalBatch` (rubric = `COLD_EVAL_RUBRIC`, candidates = the guideline/model summaries) and hand it to an **offline** judge via `maestro delegate` (zero-context, batched — NOT on the hot path). The judge returns `ColdEvalVerdict[]`.
128
+ - For each `self_sufficient=false` verdict: surface `⚠ Summary not act-on-able (cold-eval); suggested: <suggested_summary>` and route to `modify-content` (summary rewrite, stable_id preserved) — do NOT approve as-is. `self_sufficient=true` → no action.
129
+ - This is a nudge, not a hard block (KT-DEC-0007): the user may still approve over a failed verdict, but the flag must be shown.
130
+
122
131
  ## Narrowing Imported Entries & Modify Sub-Flow
123
132
 
124
133
  `modify` is the only action that mutates frontmatter or stable_id. Two paths:
@@ -1,273 +0,0 @@
1
- /**
2
- * rc.35 TASK-06 (P0-10.b) — summary-fallback library.
3
- *
4
- * Resolves opaque hint entries (where `entry.summary === entry.id` so the
5
- * AI sees no information beyond the id) by reading the entry's markdown file
6
- * from mounted store `knowledge/<type>/<id>--<slug>.md`, extracting the first
7
- * paragraph under `## Summary`, and substituting that text into the entry
8
- * before the hook renders it.
9
- *
10
- * Caching: results are stored in `.fabric/.cache/summary-fallback.json`
11
- * keyed by the current `revision_hash` returned by plan-context-hint. The
12
- * cache is wiped wholesale when the revision changes (cheap invariant —
13
- * any meta rev bump implies entry text MAY have moved). Per-process call
14
- * also benefits from in-memory dedup since the same opaque id may appear
15
- * across narrow + broad paths.
16
- *
17
- * Design contract:
18
- * - Never throw. ANY failure (cache read, fs scan, file read) degrades
19
- * to a no-op — the original opaque summary is left untouched. Hooks
20
- * must remain best-effort.
21
- * - Idempotent over identical inputs. Two calls in succession with the
22
- * same revision_hash + entries set produce zero disk reads on the
23
- * second call.
24
- *
25
- * Public API (module.exports):
26
- * resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
27
- * a NEW array of entries with `summary` substituted for opaque cases.
28
- * Original `entry.id` is preserved verbatim.
29
- *
30
- * _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
31
- *
32
- * _readCache / _writeCache — exposed for testing.
33
- */
34
-
35
- const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
36
- const { homedir } = require("node:os");
37
- const { join } = require("node:path");
38
-
39
- const CACHE_DIR_REL = ".fabric/.cache";
40
- const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
41
- const GLOBAL_CONFIG_FILE = "fabric-global.json";
42
- const PROJECT_CONFIG_REL = ".fabric/fabric-config.json";
43
- const SUMMARY_MAX_LEN = 80;
44
- const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
45
-
46
- function _isOpaque(entry) {
47
- if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
48
- return false;
49
- }
50
- return entry.summary.trim() === entry.id.trim();
51
- }
52
-
53
- /**
54
- * Pure helper: extract the first paragraph under a `## Summary` heading.
55
- *
56
- * - `## Summary` is case-insensitive but level-sensitive (only H2).
57
- * - First paragraph = lines until blank line or next heading.
58
- * - Collapses whitespace + trims; returns `""` if no summary section or
59
- * the section is empty.
60
- */
61
- function _extractFirstSummaryParagraph(md) {
62
- if (typeof md !== "string" || md.length === 0) return "";
63
- const lines = md.split(/\r?\n/);
64
- let i = 0;
65
- while (i < lines.length) {
66
- if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
67
- i += 1;
68
- break;
69
- }
70
- i += 1;
71
- }
72
- if (i >= lines.length) return "";
73
- // Skip blank lines after the heading
74
- while (i < lines.length && lines[i].trim().length === 0) i += 1;
75
- // Collect until the next blank line or next heading
76
- const buf = [];
77
- while (i < lines.length) {
78
- const line = lines[i];
79
- if (line.trim().length === 0) break;
80
- if (/^#{1,6}\s/.test(line.trim())) break;
81
- buf.push(line.trim());
82
- i += 1;
83
- }
84
- const flat = buf.join(" ").replace(/\s+/g, " ").trim();
85
- if (flat.length === 0) return "";
86
- if (flat.length <= SUMMARY_MAX_LEN) return flat;
87
- return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
88
- }
89
-
90
- function _readCache(projectRoot) {
91
- const cachePath = join(projectRoot, CACHE_FILE_REL);
92
- if (!existsSync(cachePath)) return null;
93
- try {
94
- const raw = readFileSync(cachePath, "utf8");
95
- const parsed = JSON.parse(raw);
96
- if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
97
- return parsed;
98
- }
99
- } catch {
100
- // ignore — caller treats null as no-cache
101
- }
102
- return null;
103
- }
104
-
105
- function _writeCache(projectRoot, payload) {
106
- try {
107
- const cacheDir = join(projectRoot, CACHE_DIR_REL);
108
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
109
- const cachePath = join(projectRoot, CACHE_FILE_REL);
110
- writeFileSync(cachePath, JSON.stringify(payload), "utf8");
111
- } catch {
112
- // Best-effort — failing to persist cache is not an error
113
- }
114
- }
115
-
116
- /**
117
- * Return mounted store directories in the project's read-set
118
- * (`required_stores` plus implicit personal). This hook helper is deliberately
119
- * tiny and best-effort: malformed config degrades to an empty read-set rather
120
- * than throwing during a shell hook.
121
- */
122
- function _readJson(path) {
123
- try {
124
- return JSON.parse(readFileSync(path, "utf8"));
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- function _globalRoot() {
131
- return join(process.env.FABRIC_HOME || homedir(), ".fabric");
132
- }
133
-
134
- function _storeDir(globalRoot, store) {
135
- return join(globalRoot, "stores", store.mount_name || store.store_uuid);
136
- }
137
-
138
- function _readSetStoreDirs(projectRoot) {
139
- const globalRoot = _globalRoot();
140
- const global = _readJson(join(globalRoot, GLOBAL_CONFIG_FILE));
141
- if (!global || !Array.isArray(global.stores)) return [];
142
- const project = _readJson(join(projectRoot, PROJECT_CONFIG_REL)) || {};
143
- const required = Array.isArray(project.required_stores) ? project.required_stores : [];
144
- const stores = [];
145
-
146
- for (const req of required) {
147
- if (!req || typeof req.id !== "string") continue;
148
- const matched = global.stores.find(
149
- (store) => store && !store.personal && (store.alias === req.id || store.store_uuid === req.id),
150
- );
151
- if (matched) stores.push(matched);
152
- }
153
-
154
- const personal = global.stores.find((store) => store && store.personal);
155
- if (personal) stores.push(personal);
156
-
157
- return stores.map((store) => ({
158
- alias: typeof store.alias === "string" ? store.alias : "",
159
- dir: _storeDir(globalRoot, store),
160
- }));
161
- }
162
-
163
- function _splitQualifiedId(id) {
164
- const idx = typeof id === "string" ? id.indexOf(":") : -1;
165
- if (idx <= 0) return { alias: "", stableId: id };
166
- return { alias: id.slice(0, idx), stableId: id.slice(idx + 1) };
167
- }
168
-
169
- /**
170
- * Scan mounted store `knowledge/<type>/` for the canonical `<id>--<slug>.md`
171
- * matching `stableId`. Tries the most likely type-dir first based on the
172
- * entry's `type` hint, then falls back to scanning all canonical type
173
- * directories. Returns the absolute path or null.
174
- *
175
- * The id→file mapping is unique by construction (stable_id is allocated
176
- * once per file), so the first match wins.
177
- */
178
- function _findEntryFile(projectRoot, stableId, typeHint) {
179
- const parsedId = _splitQualifiedId(stableId);
180
- const storeDirs = _readSetStoreDirs(projectRoot).filter(
181
- (store) => parsedId.alias.length === 0 || store.alias === parsedId.alias,
182
- );
183
- if (storeDirs.length === 0) return null;
184
- const tryOrder = [];
185
- if (typeof typeHint === "string" && typeHint.length > 0) {
186
- // Accept both singular and plural hints — find the plural form.
187
- const lower = typeHint.toLowerCase();
188
- const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
189
- if (plural) tryOrder.push(plural);
190
- }
191
- for (const t of KNOWLEDGE_TYPE_DIRS) {
192
- if (!tryOrder.includes(t)) tryOrder.push(t);
193
- }
194
- const prefix = `${parsedId.stableId}--`;
195
- for (const store of storeDirs) {
196
- const baseDir = join(store.dir, "knowledge");
197
- if (!existsSync(baseDir)) continue;
198
- for (const t of tryOrder) {
199
- const typeDir = join(baseDir, t);
200
- if (!existsSync(typeDir)) continue;
201
- let files;
202
- try {
203
- files = readdirSync(typeDir);
204
- } catch {
205
- continue;
206
- }
207
- for (const f of files) {
208
- if (f.startsWith(prefix) && f.endsWith(".md")) {
209
- return join(typeDir, f);
210
- }
211
- }
212
- }
213
- }
214
- return null;
215
- }
216
-
217
- function _resolveOne(projectRoot, entry) {
218
- const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
219
- if (filePath === null) return "";
220
- let md;
221
- try {
222
- md = readFileSync(filePath, "utf8");
223
- } catch {
224
- return "";
225
- }
226
- return _extractFirstSummaryParagraph(md);
227
- }
228
-
229
- /**
230
- * Main API. Returns a new array of entries with `summary` swapped for
231
- * the extracted fallback wherever the original summary was opaque AND
232
- * the fallback extraction yielded a non-empty string. Non-opaque entries
233
- * pass through unchanged.
234
- */
235
- function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
236
- if (!Array.isArray(entries) || entries.length === 0) return entries;
237
- const cache = _readCache(projectRoot);
238
- const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
239
- const nextCacheSummaries = { ...cachedSummaries };
240
- let cacheChanged = cache === null || cache.revision !== revisionHash;
241
- const result = entries.map((entry) => {
242
- if (!_isOpaque(entry)) return entry;
243
- const id = entry.id;
244
- let fallback;
245
- if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
246
- fallback = cachedSummaries[id];
247
- } else {
248
- fallback = _resolveOne(projectRoot, entry);
249
- nextCacheSummaries[id] = fallback;
250
- cacheChanged = true;
251
- }
252
- if (typeof fallback === "string" && fallback.length > 0) {
253
- return { ...entry, summary: fallback };
254
- }
255
- return entry;
256
- });
257
- if (cacheChanged) {
258
- _writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
259
- }
260
- return result;
261
- }
262
-
263
- module.exports = {
264
- resolveOpaqueSummaries,
265
- _extractFirstSummaryParagraph,
266
- _readCache,
267
- _writeCache,
268
- _findEntryFile,
269
- _readSetStoreDirs,
270
- _isOpaque,
271
- SUMMARY_MAX_LEN,
272
- KNOWLEDGE_TYPE_DIRS,
273
- };