dsh-engram 0.3.3 → 0.3.5

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.
@@ -0,0 +1,156 @@
1
+ # dsh-engram 改进方案 · 借自 Hindsight(三个概念,不引重路线)
2
+
3
+ > 依据:[vectorize-io/hindsight](https://github.com/vectorize-io/hindsight) 深度研究(2026 春季快照)。
4
+ > 原则:三条全部**纯规则 / 无向量 / 无 LLM 蒸馏 / 确定性可单测**,符合既有约束
5
+ > (不引重路线、中文、host 改动等自然重启、dock 极简)。
6
+ > 真实基线:Kototoro memories 71(扁平)/ tasks 1 / links 4 / nodes 3;toolstats 近 14 天
7
+ > todo 48 · ESR ~6 · esr_node/esr_link 接近零。
8
+
9
+ ---
10
+
11
+ ## 总览
12
+
13
+ | # | 概念(来源) | 解决什么 | 落点 | 成本 | 依赖 |
14
+ |---|---|---|---|---|---|
15
+ | A | **Observation 范式**:证据引用 + proof_count + refine-not-overwrite(consolidator.py) | 71 条扁平记忆无从浓缩;失败/成功经验被覆盖不保留证据链 | `lib/store.js` 写入侧 + `lib/obs.js` 新模块 + board 观测区 | host ~250 行 | 无 |
16
+ | B | **Mental model 预计算**:常驻答案读零计算 + 水印刷新(mental_models + last_memory_seen_at) | 每次会话现查照样费 tokens;dock/board 每次重算 | `lib/mental.js` + 新工具 `esr_model` + board 常驻条 | host ~200 行 | 依赖 A 提供的 observation 汇总 |
17
+ | C | **召回重排信号**:proof_count boost × recency 半衰期(reranking.py) | 现行排序只有 recency+精确匹配;失败复活性差 | `lib/rerank.js` 纯函数 + bm25Rank 接入 | host ~80 行 | 无 |
18
+
19
+ 状态:C ✅(7093fb5)/ A ✅(7103a22)/ B ✅。建议实施顺序:**C → A → B**。
20
+ B 落地要点:`models` 表注册(ws/content/generated_at/dirty/sources_hash);
21
+ `lib/mental.js` 纯函数 compileSummary(任务/实体/观测/记忆种类聚合 → markdown)、
22
+ computeSourcesHash、modelStale(dirty ∨ 超 10min ∨ 输入 hash 变)、getModel(冷缓存重算并持久化);
23
+ esr_* 六个写工具成功路径统一 `markModelDirty`;agent 工具 `esr_model`(返回常驻摘要+生成分钟数)
24
+ 与 `/model` 端点;board 顶部可折叠「常驻摘要 · 生成于 N 分钟前」。新测试 6 个(宿主 67/67)。
25
+ C/A/B 的 host 部分(rerank、/observations、/model、esr_model、dirty 钩子)随下次 `dsh web` 重启生效;
26
+ board 观测条/常驻条 client-hmr 即时生效。
27
+ A 落地要点(相对本稿):`observations` 表注册进 `engramDomainSpec`;
28
+ `storeMemory` 三路(新建/exact-dup/失败复活)均触发 `integrateObservation`,其中
29
+ **失败复活(revive)是「新发生」→ forceEvidence 爬 proof**,exact-dup(同源)只刷
30
+ span;`engram_recall` 尾部注入 proof≥2 的观测段;board 加可折叠「观测 · 信念 N」条;
31
+ `/observations` 端点与 /toolstats 同批等重启;新测试 12 个(宿主 61/61)。
32
+
33
+ ---
34
+
35
+ ## A. Observation:证据浓缩 + 证据计数 + 增强而非覆盖
36
+
37
+ ### 问题
38
+ - 记忆是扁平列表;同一主题的多次失败/多次成功没有聚合,`esr_recall` 返回的是散点。
39
+ - D1/D3 的「失败记忆复活」只恢复了单条,没有形成「这个模式被证明 N 次」的信念。
40
+
41
+ ### 数据模型(新增 `tables.observations`,存 storages/dsh_engram.json)
42
+ ```jsonc
43
+ {
44
+ "id": "obs_<8>",
45
+ "ws": "/ws/A", // "" = 全局
46
+ "text": "浓缩后的信念语句(初始 = 触发记忆文本)",
47
+ "kind": "belief", // belief | pattern | risk(由触发记忆 tags 推导,不引 LLM)
48
+ "proof": { "count": 3, "sources": ["mem_<8>", ...] },
49
+ "span": { "first_seen_at": 1, "last_seen_at": 2 },
50
+ "negations": 0, // 反证计数 → 触发 weaken/stale
51
+ "trend": "stable", // new|strengthening|stable|weakening|stale(算法计算)
52
+ "tags": ["fail"],
53
+ "updated_at": 3
54
+ }
55
+ ```
56
+
57
+ ### 算法(`lib/obs.js`,全部纯函数 + 一个写入钩子)
58
+ 1. **`concentrate(ws, memText, memId, tags)`** —— host 写入记忆(esr_store_memory / 失败复活 / engram 自动捕获)后调用:
59
+ - 桶化:按 `(entity 锚点 或 tags) + 字符 n-gram Jaccard ≥ 阈值(0.45)` 找既有 observation(中文无需向量)。
60
+ - 命中 → **merge 而非覆盖**:`proof.count+1`、`sources.push(memId)`、`span.last_seen_at = now`、`updated_at=now`;`text` 仅在新增细节时 append(简单规则:新文本含旧文本不存在的 token 段则并句)。
61
+ - 命中且可判定为**反证**(tags 含 `error`+当前 text 与旧 text 出现否定词对)→ `negations+1`,`proof.count` 不变。
62
+ - 未命中 → 新建 observation(`proof.count=1`)。
63
+ 2. **`computeTrend(span, now, recentDays=30, oldDays=90)`** —— 借鉴 observations.py 的 `Trend`:
64
+ - last_seen 在 30 天内且 first_seen 更早 → `strengthening`
65
+ - 全部证据在 30 天内 → `new`
66
+ - 90 天内有证据 → `stable`
67
+ - 90 天内无新证据但有历史 → `weakening`;无证据 → 不存在的桶自清(GC 归入 esr_gc)。
68
+ 3. **读取注入**:`esr_recall` 把 `proof.count ≥ 2` 的 observations 作为高权重候选混入返回(标记 `source: observation`),证据门语义不变。
69
+
70
+ ### Web 展示
71
+ - 只进 **board**(dock 保持极简):columns 上方一行可折叠「观测 · N 条信念」条,展示 `text · ✓×count · 趋势图标`,展开可看 sources 列表。
72
+ - KPI 行(toolstats 那行)旁加 `观测 N`。
73
+
74
+ ### 测试
75
+ `test/obs.test.mjs`(node:test 直接 import):
76
+ - 首次写入建桶 / 二次相同语义 merge(count 2 且 sources 追加)/ 反证 negations+1 /
77
+ trend 四种时间窗 / 阈值下不误并 / Jaccard 中文样例。
78
+
79
+ ---
80
+
81
+ ## B. Mental model:常驻摘要 + 水印刷新
82
+
83
+ ### 问题
84
+ - agent 每轮现查「这个工作区现在什么状态」要花多次 recall;看板每次挂载重算 overview + tasks + links。
85
+ - 产出一个可被反复读取、只在数据变化时才重算的「常驻答案」→ 读零计算。
86
+
87
+ ### 数据模型(新增 `tables.models`)
88
+ ```jsonc
89
+ {
90
+ "ws": "/ws/A",
91
+ "content": "## /ws/A 常驻摘要\n- 任务:2 active(1 就绪)/ 1 stable\n- 实体 3 · 链接 4\n- 观测 6 条信念(2 失败模式已重复 3 次)\n- 近 14 天:todo 48 · ESR 6\n- 生成于 N 分钟前",
92
+ "generated_at": 1,
93
+ "dirty": true, // 任何 esr_* 写操作后置 true
94
+ "sources_hash": "sha256 摘要(惰性校验可跳过重算)"
95
+ }
96
+ ```
97
+
98
+ ### 算法(`lib/mental.js`)
99
+ - **`bumpDirty(ws)`** —— host 工具写钩子(esr_task/esr_close/esr_node/esr_link/esr_store_memory/todo 沉淀)之后调用。
100
+ - **`getModel(ws)`** —— 读缓存;`dirty` 或过期(>10min)则**纯聚合重算**(无 LLM):复用 `domain` API 的 tasks/entities/links 计数 + `collectToolCounts` 的近况 + `tables.observations` 的 top 信念 → 拼 markdown;重算后 `dirty=false`。
101
+ - **暴露**:
102
+ - agent 工具 `esr_model(ws?)` —— 返回常驻摘要 markdown(**零 LLM、一次 DB 读**)放在工具说明顶部「先看 model 再 recall」;response 带 `modeling:` 行同款风格。
103
+ - web endpoint `/model?ws=`(route 注册随下次重启,与 /toolstats 同批)。
104
+
105
+ ### Web 展示
106
+ - board 顶部「常驻摘要 · 生成于 N 分钟前」可折叠条(可复制、可下载为 md——复用自己的 buildTasksMarkdown 思路,纯 client 无依赖)。
107
+
108
+ ### 测试
109
+ `test/mental.test.mjs`:dirty→重算 / 未 dirty→命中缓存(generated_at 不变)/ 写操作触发 bump(route 注入假 service)/ 空工作区占位内容。
110
+
111
+ ---
112
+
113
+ ## C. 召回重排:proof_count × recency 半衰期
114
+
115
+ ### 问题与现状核实
116
+ - 现行 `util.js bm25Rank` **已含** recency 半衰期因子 `1 + 0.5·exp(-ageDays/14)`(14 天、最多 +50%,
117
+ 不覆盖强词法相关),也有 tag-exact/fuzzy/phrase/BM25 权重——真正缺失的是**证据强度信号**:
118
+ 同样词频的记忆,被证明过 N 次的(hits)没有被排序优待。
119
+ - D2 失败复活已在 error 记忆重复时递增 `hits`;它就是现成的 proof 计数。
120
+
121
+ ### 实现(已落地:`lib/rerank.js` + `util.js bm25Rank` 接线)
122
+ ```
123
+ score_final = score_lexical // BM25 + tag/phrase boost(既有)
124
+ × (1 + 0.5·exp(-ageDays/14)) // recency 半衰期(既有)
125
+ × evidenceBoost(hits) // 新增:1 + 0.1·min(max(0,hits), 5),饱和 ±5%
126
+ ```
127
+ - `evidenceBoost(hits, {alpha=0.1, cap=5})`:0→1.0、5→1.5(封顶),保守不喧宾夺主(借鉴 reranking.py 的 proof alpha)。
128
+ - `scoreCandidate(matchScore, hits)`、`sortByEvidence(list, opts)`(稳定、可注入 tiebreak)。
129
+ - 接入点:`bm25Rank` 词法分后乘 `evidenceBoost(r.hits ?? 0)`——encerence recall/searchMemories 全走这里,零额外改动。
130
+ - 「失败复活」天然增强:高 hits 的失败记忆在同词频下与新鲜记忆竞争更接近。
131
+
132
+ ### 测试
133
+ `test/rerank.test.mjs`:三种 matchKind 基准 / 半衰期形状(t=0, halflife, 2*halflife 分别为 1, 0.5, 0.25)/ proof cap / 稳定排序(同分保序)/ 未来时间戳钳制。
134
+
135
+ ---
136
+
137
+ ## 分阶段计划(每阶段:代码 + 测试 + client-hmr 即时 / host 待重启 + 中文总结)
138
+
139
+ | 阶段 | 内容 | 交付物 | 生效 |
140
+ |---|---|---|---|
141
+ | C | rerank 纯函数 + 接入 esr_recall | `lib/rerank.js` + `test/rerank.test.mjs` | 等自然重启 |
142
+ | A | observations 表 + concentrate/trend + recall 注入 + board 观测条 | `lib/obs.js` + `lib/store.js` 钩子 + `test/obs.test.mjs` + board | host 待重启;board 即时 |
143
+ | B | mental 缓存 + esr_model 工具 + /model 端点 + board 常驻条 | `lib/mental.js` + `test/mental.test.mjs` + board | host 待重启;board 即时 |
144
+
145
+ 每阶段与既有实现正交:不动 dock 交互(除可选 KPI 一列)、不动证据门、不动 esr_close 语义。
146
+
147
+ ## 明确不做(保留约束)
148
+ - 不做 LLM 抽取/浓缩(Hindsight retain 管线)——成本与我们的形态不匹配。
149
+ - 不做向量/图库/交叉编码器重排。
150
+ - 不做 mental model 的「用户定义问题」自由文本——用固定模板,纯聚合。
151
+ - observations 的「来源逐 quote 引用」简化为 sources 列表(证据门已足够)。
152
+
153
+ ## 风险
154
+ - 浓缩误并(Jaccard 阈值误命中)→ 反证机制 + GC 可兜;阈值可配。
155
+ - observation 桶增长失控 → 每 ws 上限(默认 50 条),超出按 proof 降序逐出(保留计数进 audit),归入 esr_gc。
156
+ - mental 缓存脏标记漏置 → `bumpDirty` 挂全量写钩子 + 过期兜底(10min 强制重算)。
package/lib/api.js CHANGED
@@ -157,6 +157,7 @@ export function makeEngramRoutes(service) {
157
157
  indexes,
158
158
  captures: service.captureStats ?? { total: 0, git: 0, file: 0, error: 0 },
159
159
  gc: service.gcStats ?? { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
160
+ compaction: service.compactionStatus ?? { host: "unknown", web: { action: "unknown", presets: [] } },
160
161
  config: service.config ?? {},
161
162
  anonymous: {
162
163
  version: 2,
@@ -180,6 +181,23 @@ export function makeEngramRoutes(service) {
180
181
  writeJson(res, 200, { items });
181
182
  };
182
183
 
184
+ // Goal-domain sediment: memories auto-captured from `goal/change` events
185
+ // (tag "goal"). A compact read surface for the GUI, backed by the same
186
+ // searchMemory view as /memories.
187
+ const goals = async (req, res) => {
188
+ const domain = await withDomain(res);
189
+ if (domain === void 0) return;
190
+ const params = readQuery(req);
191
+ const limit = Number(params.get("limit") ?? 50);
192
+ const items = domain.searchMemories({
193
+ workspace: params.get("workspace") || void 0,
194
+ tag: "goal",
195
+ status: "active",
196
+ limit,
197
+ }).map((m) => ({ id: m.id, kind: m.kind, text: m.text, createdAt: m.createdAt, workspace: m.workspace }));
198
+ writeJson(res, 200, { items });
199
+ };
200
+
183
201
  const tasks = async (req, res) => {
184
202
  const domain = await withDomain(res);
185
203
  if (domain === void 0) return;
@@ -503,6 +521,7 @@ export function makeEngramRoutes(service) {
503
521
  return [
504
522
  make("GET", "/overview", overview),
505
523
  make("GET", "/memories", memories),
524
+ make("GET", "/goals", goals),
506
525
  makeUnion("/tasks", { GET: tasks, POST: createTask }),
507
526
  make("GET", "/links", links),
508
527
  make("GET", "/nodes", entities),
package/lib/capture.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * branch / tag / cherry-pick). Pure plumbing (`push`, `stash`, `status`)
12
12
  * is deliberately NOT captured: those are operational echoes, not lasting
13
13
  * decisions, and would only drown the memory pool and the [ENGRAM] index.
14
+ * - failing test runs (`npm test` / `node --test` / vitest / jest / pytest)
15
+ * as high-signal error memories tagged `test`.
14
16
  * - writes/edits to significant config & doc paths
15
17
  * - repeated tool errors (error signal, deduped by message)
16
18
  * symbolic-index's own insight: one file edit is noise; accumulated edits and rare
@@ -22,6 +24,56 @@ import { workspaceKey as workspaceKeyOf } from "./util.js";
22
24
 
23
25
  const GIT_MILESTONE_SIGNAL = 0.5;
24
26
  const GIT_COMMIT_SIGNAL = 0.55;
27
+ const TEST_FAILURE_SIGNAL = 0.35;
28
+ const FIX_CLOSURE_SIGNAL = 0.45;
29
+
30
+ /** Concrete text: a test-run command (npm/pnpm/yarn/bun/npx/node --test/…). */
31
+ const TEST_RUN_RE = /\b(npm|pnpm|yarn|bun|npx|corepack)\s+(?:run\s+)?test\b|\bnode\s+--test\b|\bvitest\b|\bpytest\b|\bjest\b/;
32
+ /** A result line that reads as a failure point (deterministic keyword scan). */
33
+ const FAILURE_LINE_RE = /(assertionerror|#\s*fail(?:ed)?|not\s+ok|✗|✖|✘|\bFAIL(?:ED)?\b|tests?\s+failed|failed\s+tests?\b|\d+\s+failed|non-zero exit|exception|traceback|error:\s)/i;
34
+
35
+ /**
36
+ * Normalize a command to a stable short signature tag (first two tokens,
37
+ * spaces → underscores, ≤48 chars) used to pair a failure with its later
38
+ * successful run. Returns null for empty commands.
39
+ */
40
+ export function cmdTag(command) {
41
+ const tokens = String(command ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 2);
42
+ if (tokens.length === 0) return null;
43
+ return `cmd:${tokens.join("_").slice(0, 48)}`;
44
+ }
45
+
46
+ /**
47
+ * Failure→fix closure (zero LLM): when a command that previously failed (an
48
+ * ACTIVE kind:error memory tagged with the same `cmd:` signature) now runs
49
+ * successfully, produce a `procedure` memory plus the ids of the resolved
50
+ * error rows. Deterministic; fires only on genuine same-command
51
+ * success-after-failure.
52
+ */
53
+ export function fixClosureEntry(name, command, result, previousErrors) {
54
+ if (!["bash", "shell", "exec"].includes(name)) return null;
55
+ if (result?.isError === true) return null; // not a success
56
+ if (typeof command !== "string" || command.trim().length === 0) return null;
57
+ const tag = cmdTag(command);
58
+ if (tag === null) return null;
59
+ const failing = (previousErrors ?? []).filter(
60
+ (m) => m.kind === "error" && m.status === "active" && (m.tags ?? []).includes(tag),
61
+ );
62
+ if (failing.length === 0) return null;
63
+ const head = command.trim().split(/\s+/).slice(0, 2).join(" ");
64
+ const isTest = TEST_RUN_RE.test(command);
65
+ const plural = failing.length > 1 ? "s" : "";
66
+ return {
67
+ entry: {
68
+ kind: "procedure",
69
+ text: `fixed: ${head} — ${failing.length} earlier failing run${plural} now succeed`,
70
+ signal: FIX_CLOSURE_SIGNAL,
71
+ tags: ["procedure", "auto-captured", "fix", tag, ...(isTest ? ["test"] : [])],
72
+ filePath: null,
73
+ },
74
+ resolvedIds: failing.map((m) => m.id),
75
+ };
76
+ }
25
77
 
26
78
  /** Git operations that carry lasting meaning (milestones). */
27
79
  const GIT_OPS = [
@@ -109,9 +161,30 @@ export function makeCaptureHandler(store, config, log, stats) {
109
161
  // Errors take precedence over tool-name classification: a failed edit
110
162
  // tool is a FAILURE of that file, not an "Edited" event (filePath anchor).
111
163
  const errPath = String(args.path ?? args.file_path ?? args.filePath ?? "");
112
- const msg = errorHead(result);
113
- if (msg !== null) {
114
- entry = { kind: "error", text: `${name} failed: ${msg}`, signal: 0.25, tags: ["error", "auto-captured"], filePath: errPath || null };
164
+ const cmd = cmdTag(args.command); // pairs this failure with a later success
165
+ // A failing test run is the highest-value coding signal: name it
166
+ // explicitly and surface the concrete failure point.
167
+ const testFail = extractTestFailure(name, args.command, result);
168
+ if (testFail !== null) {
169
+ entry = { kind: "error", text: testFail, signal: TEST_FAILURE_SIGNAL, tags: ["error", "test", "auto-captured", cmd].filter(Boolean), filePath: null };
170
+ } else {
171
+ const msg = errorHead(result);
172
+ if (msg !== null) {
173
+ entry = { kind: "error", text: `${name} failed: ${msg}`, signal: 0.25, tags: ["error", "auto-captured", cmd].filter(Boolean), filePath: errPath || null };
174
+ }
175
+ }
176
+ } else if (isCommandRun(name) && typeof args.command === "string" && store.listMemories !== void 0) {
177
+ // Failure→fix closure: the same command that once failed now succeeds —
178
+ // sediment a `procedure` and resolve the earlier error rows.
179
+ const fix = fixClosureEntry(
180
+ name,
181
+ args.command,
182
+ result,
183
+ store.listMemories(workspaceKeyOf(cwd), 200),
184
+ );
185
+ if (fix !== null) {
186
+ entry = fix.entry;
187
+ entry._resolvedIds = fix.resolvedIds;
115
188
  }
116
189
  } else if (EDIT_TOOLS.has(name)) {
117
190
  const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
@@ -142,6 +215,7 @@ export function makeCaptureHandler(store, config, log, stats) {
142
215
  recent.delete(first);
143
216
  }
144
217
 
218
+ const resolvedIds = entry._resolvedIds ?? [];
145
219
  void store
146
220
  .storeMemory(
147
221
  {
@@ -156,7 +230,13 @@ export function makeCaptureHandler(store, config, log, stats) {
156
230
  },
157
231
  config,
158
232
  )
159
- .then(() => {})
233
+ .then(() => {
234
+ // Failure→fix closure: mark the earlier error rows resolved (tag
235
+ // append only — never re-ranks them, per store.tagMemory's contract).
236
+ for (const id of resolvedIds) {
237
+ void store.tagMemory?.(workspaceKeyOf(cwd), id, "resolved")?.catch?.(() => null);
238
+ }
239
+ })
160
240
  .catch((error) => log?.warn?.(`engram auto-capture failed: ${String(error)}`));
161
241
  } catch (error) {
162
242
  // fully contained — a capture bug must never disturb the tool pipeline
@@ -187,6 +267,11 @@ function extractGit(name, command) {
187
267
  return null;
188
268
  }
189
269
 
270
+ /** Shell-run tool names whose failures/successes carry a command signature. */
271
+ function isCommandRun(name) {
272
+ return name === "bash" || name === "shell" || name === "exec";
273
+ }
274
+
190
275
  function errorHead(result) {
191
276
  // canonical value or rendered content
192
277
  const value = result?.value;
@@ -206,6 +291,49 @@ function errorHead(result) {
206
291
  return null;
207
292
  }
208
293
 
294
+ /**
295
+ * When a bash/shell run of a test command exits non-zero, produce a
296
+ * deterministic failure memory: `tests failed (<cmd>): <failure line>`.
297
+ * Returns null for non-test commands, successful runs, or when no failure
298
+ * line can be pinned down (generic error extraction then applies).
299
+ */
300
+ function extractTestFailure(name, command, result) {
301
+ if (name !== "bash" && name !== "shell" && name !== "exec") return null;
302
+ if (typeof command !== "string" || !TEST_RUN_RE.test(command)) return null;
303
+ if (result?.isError !== true) return null;
304
+ const line = failureLine(result);
305
+ if (line === null) return null;
306
+ const head = command.trim().split(/\s+/).slice(0, 2).join(" ");
307
+ return `tests failed (${head}): ${line}`;
308
+ }
309
+
310
+ /** First failure-flavoured line of a result (≤120 chars), else its head. */
311
+ function failureLine(result) {
312
+ const blocks = [];
313
+ const value = result?.value;
314
+ if (typeof value === "string" && value.length > 0) blocks.push(value);
315
+ if (value !== null && typeof value === "object") {
316
+ for (const k of ["error", "stderr", "message", "stdout"]) {
317
+ if (typeof value[k] === "string" && value[k].length > 0) blocks.push(value[k]);
318
+ }
319
+ }
320
+ const content = result?.content;
321
+ if (Array.isArray(content)) {
322
+ for (const block of content) {
323
+ if (block?.type === "text" && typeof block.text === "string") blocks.push(block.text);
324
+ }
325
+ }
326
+ for (const block of blocks) {
327
+ const lines = block.split("\n");
328
+ for (const line of lines) {
329
+ const trimmed = line.trim().replace(/\s+/g, " ").slice(0, 120);
330
+ if (FAILURE_LINE_RE.test(trimmed)) return trimmed;
331
+ }
332
+ }
333
+ const first = blocks[0];
334
+ return typeof first === "string" && first.length > 0 ? first.split("\n")[0].trim().slice(0, 120) : null;
335
+ }
336
+
209
337
  function dirOf(path) {
210
338
  const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
211
339
  return i > 0 ? path.slice(0, i) : ".";