mover-os 4.7.8 → 4.7.10
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 +34 -24
- package/install.js +2229 -204
- package/package.json +3 -2
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
|
@@ -15,23 +15,30 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
|
|
|
15
15
|
now,
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
const current = parsed.current;
|
|
18
|
+
const current = parsed.current; // null when progress is unmeasured
|
|
19
19
|
const target = parsed.target;
|
|
20
|
-
const
|
|
20
|
+
const hasProgress = current != null;
|
|
21
|
+
const needed = (target && hasProgress) ? Math.max(0, target - current) : null;
|
|
21
22
|
const deadline = parsed.deadline;
|
|
22
23
|
const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
|
|
23
24
|
const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
|
|
24
|
-
const requiredPerDay = daysSafe == null
|
|
25
|
+
const requiredPerDay = (needed == null || daysSafe == null)
|
|
25
26
|
? null
|
|
26
27
|
: daysSafe === 0 ? needed : round(needed / daysSafe, 2);
|
|
27
|
-
|
|
28
|
+
// progressPct is null (UNMEASURED), never a false 0, when no scoped progress exists.
|
|
29
|
+
const progressPct = (target > 0 && hasProgress) ? Math.min(100, Math.round((current / target) * 100)) : null;
|
|
28
30
|
|
|
31
|
+
// Status is scoped to the OUTCOME. A real dated target with no scoped progress
|
|
32
|
+
// reads "unknown" (-> "Not measured" in the UI), never a fabricated on-track. A
|
|
33
|
+
// scoped zero reads on-track (0 of 2, deadline ahead). [sol #11 cross-scope]
|
|
29
34
|
let status = "unknown";
|
|
30
|
-
if (target
|
|
35
|
+
if (!target) status = "unknown";
|
|
36
|
+
else if (hasProgress && current >= target) status = "complete";
|
|
31
37
|
else if (daysRemaining != null && daysRemaining < 0) status = "failed";
|
|
38
|
+
else if (!hasProgress) status = "unknown";
|
|
32
39
|
else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
|
|
33
40
|
else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
|
|
34
|
-
else
|
|
41
|
+
else status = "on-track";
|
|
35
42
|
|
|
36
43
|
// Only act on distribution we actually measured. Unavailable (null) distribution
|
|
37
44
|
// must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
|
|
@@ -57,7 +64,7 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
|
|
|
57
64
|
pushAction(adjustments, workflows, {
|
|
58
65
|
workflow: "/pivot-strategy",
|
|
59
66
|
title: status === "failed" ? "Close the failed Single Test" : "Recalibrate the current strategy",
|
|
60
|
-
detail: `${current}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
|
|
67
|
+
detail: `${current ?? "—"}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
|
|
61
68
|
priority: status === "failed" ? "critical" : "high",
|
|
62
69
|
});
|
|
63
70
|
}
|
|
@@ -85,14 +92,34 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
|
|
|
85
92
|
return {
|
|
86
93
|
available,
|
|
87
94
|
singleTest: parsed.singleTest || null,
|
|
95
|
+
// current stays null when the target is parseable but progress is unmeasured
|
|
96
|
+
// (a clean bet with no scoped record) — the UI shows "Progress not measured".
|
|
88
97
|
current: available ? current : null,
|
|
89
98
|
target: available ? target : null,
|
|
90
99
|
unit: parsed.unit,
|
|
100
|
+
// kind rides with the parsed counter when one exists (the verdict must
|
|
101
|
+
// cite what the numbers actually count); keyword classification only
|
|
102
|
+
// labels the no-counter cases (revenue/vitality wording, available:false).
|
|
103
|
+
kind: parsed.counterKind || classifyGoalKind(strategy && strategy.singleTest),
|
|
104
|
+
// shape: "clean" (one metric + date), "compound" (a multi-signal gate), or
|
|
105
|
+
// "unparseable" (no target the dashboard can score). The hero renders each
|
|
106
|
+
// differently; a compound/legacy bet gets a "rewrite for clarity" affordance.
|
|
107
|
+
gate: parsed.gate === true,
|
|
108
|
+
shape: parsed.shape || (parsed.gate ? "compound" : (available ? "clean" : "unparseable")),
|
|
109
|
+
// whether `current` came from a real test-scoped record vs is just unmeasured.
|
|
110
|
+
progressMeasured: parsed.progressMeasured === true,
|
|
111
|
+
// the input commitment (daily quota) + the channel it names, both SEPARATE
|
|
112
|
+
// from the bet — Today renders these, never the hero.
|
|
113
|
+
commitment: (strategy && strategy.commitment) || null,
|
|
114
|
+
channel: parseChannel(strategy && strategy.commitment),
|
|
91
115
|
deadline,
|
|
92
116
|
daysRemaining,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
// Derived pace numerals are honest-null when there is no measurable target
|
|
118
|
+
// (a gate/unparseable test) OR when the target is real but progress is
|
|
119
|
+
// unmeasured. Secondary readers that copy them raw must not report a false 0%.
|
|
120
|
+
needed: available ? needed : null,
|
|
121
|
+
requiredPerDay: available ? requiredPerDay : null,
|
|
122
|
+
progressPct: available ? progressPct : null,
|
|
96
123
|
status,
|
|
97
124
|
source: parsed.source,
|
|
98
125
|
distribution7d: total7dKnown ? total7d : null,
|
|
@@ -106,22 +133,87 @@ function pushAction(adjustments, workflows, action) {
|
|
|
106
133
|
if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
// The channel named in the Commitment ("...via X replies and DMs; ...") — the
|
|
137
|
+
// hero's support line uses it ("From X replies and DMs, by Jul 26"). Null-safe.
|
|
138
|
+
function parseChannel(commitment) {
|
|
139
|
+
if (!commitment) return null;
|
|
140
|
+
const m = String(commitment).match(/\b(?:via|through|on|using)\s+([^;.,]+?)(?=\s*[;.,]|$)/i);
|
|
141
|
+
return m ? m[1].trim() : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A scoped progress record's unit must be the SAME kind of thing as the target.
|
|
145
|
+
// The HEAD noun (last word) must match, so "0/2 paying customers" counts toward a
|
|
146
|
+
// "customers" target, but "2/2 customer support calls" (head = calls) and
|
|
147
|
+
// "1/2 customer interviews" (head = interviews) do NOT — a shared adjective is not
|
|
148
|
+
// a unit match, or a same-denominator side metric fabricates "Complete". [sol #12]
|
|
149
|
+
function unitFamilyMatch(scopedUnit, targetUnit) {
|
|
150
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z ]+/g, " ").trim().replace(/s\b/g, "");
|
|
151
|
+
const t = norm(targetUnit).split(/\s+/).filter(Boolean);
|
|
152
|
+
const s = norm(scopedUnit).split(/\s+/).filter(Boolean);
|
|
153
|
+
if (!t.length || !s.length) return false;
|
|
154
|
+
return t[t.length - 1] === s[s.length - 1]; // head nouns must be identical
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// What currency is the goal stated in, and does anything on file measure it?
|
|
158
|
+
// Drives the goal-anchored verdict on Evidence: an installs goal is pushed by
|
|
159
|
+
// distribution receipts; a distribution goal IS the receipts; a revenue or
|
|
160
|
+
// vitality goal has no parser yet, so the card must say "unmeasured" instead
|
|
161
|
+
// of pretending workflow counts answer it. Order matters: adoption phrasing
|
|
162
|
+
// ("15 activated installs") usually also names the channel, so adoption wins
|
|
163
|
+
// when both appear. Returns null when nothing matches (generic verdict).
|
|
164
|
+
function classifyGoalKind(singleTest) {
|
|
165
|
+
const t = String(singleTest || "").toLowerCase();
|
|
166
|
+
if (!t) return null;
|
|
167
|
+
if (/\b(installs?|activat|referrals?|sign-?ups?|customers?|users?|sales?|purchases?|buyers?|strangers?)/.test(t)) return "adoption";
|
|
168
|
+
if (/\b(dms?|outreach|repl(y|ies)|posts?|publish\w*|distribut\w*|articles?|reels?|videos?|content|newsletters?|followers?|subscribers?|views?|audience|impressions?)\b/.test(t)) return "distribution";
|
|
169
|
+
if (/(revenue|mrr\b|income|profit|£|\$|€)/.test(t)) return "revenue";
|
|
170
|
+
if (/\b(sleep|gym|weight|prayers?|fajr|salah|steps|health|energy|fasting)\b/.test(t)) return "vitality";
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
109
174
|
function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
|
|
110
175
|
const out = {
|
|
111
176
|
singleTest: singleTest || null,
|
|
112
|
-
current:
|
|
177
|
+
current: null, // measured ONLY from a test-scoped record; null = unmeasured
|
|
113
178
|
target: 0,
|
|
114
179
|
unit: "customers",
|
|
180
|
+
counterKind: null,
|
|
181
|
+
shape: "unparseable",
|
|
182
|
+
progressMeasured: false,
|
|
115
183
|
deadline: null,
|
|
116
184
|
source: "strategy",
|
|
117
185
|
};
|
|
118
186
|
|
|
119
187
|
if (singleTest) {
|
|
188
|
+
// A multi-signal GATE ("Hit 2 of 3": 10 conversations / 5 install starts /
|
|
189
|
+
// 2 paid) is not one countable number. The single-counter regex below would
|
|
190
|
+
// grab "5 install starts" as target=5 and pair it with an unrelated sales
|
|
191
|
+
// count elsewhere on file, fabricating "4 of 5 installs". Detect the gate,
|
|
192
|
+
// refuse to collapse it, keep the deadline so the card can still pace to the
|
|
193
|
+
// date, and let the UI render the honest "scored by hand" state.
|
|
194
|
+
//
|
|
195
|
+
// The marker must be a gate VERDICT, not a bare "N of M": require a scoring
|
|
196
|
+
// verb (hit/pass/meet/clear, "of" or "out of") OR an "N of M =" verdict
|
|
197
|
+
// clause. A bare "N of M" would false-trigger on "Day 2 of 14: send 50 DMs"
|
|
198
|
+
// (a real distribution counter) and hide it; the verb/"=" gate keeps those
|
|
199
|
+
// parsing normally. A "+" rider ("15 installs + 1 referral") has no "of" at
|
|
200
|
+
// all and still parses its primary counter. [gate honesty, sol #10 hardening]
|
|
201
|
+
if (/\b(?:hit|hitting|pass(?:ed)?|meet|met|clear(?:ed)?)\s+\d+\s+(?:out\s+of|of)\s+\d+\b|\b\d+\s+(?:out\s+of|of)\s+\d+\s*=/i.test(singleTest)) {
|
|
202
|
+
out.gate = true;
|
|
203
|
+
out.counterKind = "gate";
|
|
204
|
+
out.shape = "compound";
|
|
205
|
+
out.deadline = extractDate(singleTest, now) || targetDate || null;
|
|
206
|
+
return out; // target stays 0 -> available:false -> honest-null, no fabricated counter
|
|
207
|
+
}
|
|
120
208
|
// Regex captures count-based phrasing (e.g. "10 paying customers/sales")
|
|
121
209
|
// AND activation phrasing ("N activated installs / referrals"). The
|
|
122
210
|
// extended unit list keeps backwards-compat and makes an activation-style
|
|
123
211
|
// mid-term metric ("N activated installs + M referrals by <date>") parse correctly.
|
|
124
|
-
|
|
212
|
+
// The negative lookahead stops an adoption noun acting as a MODIFIER of a
|
|
213
|
+
// distribution noun from being read as the counter: "10 sales posts" is a
|
|
214
|
+
// publishing target, never a sales counter. [terra F3, 2026-07-10]
|
|
215
|
+
const DIST_NOUN = "posts?|dms?|reels?|articles?|videos?|emails?|threads?|newsletters?";
|
|
216
|
+
const targetMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:paying\\s+|activated\\s+)?(?:strangers?|customers?|sales|users?|installs?|referrals?|activations?)\\b(?!\\s+(?:" + DIST_NOUN + ")\\b)", "i"));
|
|
125
217
|
if (targetMatch) {
|
|
126
218
|
out.target = Number(targetMatch[1]);
|
|
127
219
|
// Set unit from the matched phrase so the hero strip says
|
|
@@ -131,29 +223,45 @@ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
|
|
|
131
223
|
else if (/referral/.test(phrase)) out.unit = "referrals";
|
|
132
224
|
else if (/user/.test(phrase)) out.unit = "users";
|
|
133
225
|
else out.unit = "customers";
|
|
226
|
+
out.counterKind = "adoption";
|
|
227
|
+
out.shape = "clean";
|
|
228
|
+
} else {
|
|
229
|
+
// Distribution counter ("publish 10 posts", "send 50 DMs", "10 sales
|
|
230
|
+
// posts"): the kind rides with the SAME match that set the number, so
|
|
231
|
+
// the verdict can never cite a counter the parse didn't produce.
|
|
232
|
+
const distMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:[a-z-]+\\s+)?(" + DIST_NOUN + ")\\b", "i"));
|
|
233
|
+
if (distMatch) {
|
|
234
|
+
out.target = Number(distMatch[1]);
|
|
235
|
+
out.unit = distMatch[2].toLowerCase();
|
|
236
|
+
out.counterKind = "distribution";
|
|
237
|
+
out.shape = "clean";
|
|
238
|
+
}
|
|
134
239
|
}
|
|
135
240
|
const deadline = extractDate(singleTest, now) || targetDate;
|
|
136
241
|
if (deadline) out.deadline = deadline;
|
|
137
242
|
}
|
|
138
243
|
|
|
139
244
|
const text = sourceText || "";
|
|
140
|
-
const ratios = Array.from(text.matchAll(/\b(\d+)\s*\/\s*(\d+)\s*(?:sales|paying|customers?|strangers?|users?)\b/gi));
|
|
141
|
-
if (ratios.length) {
|
|
142
|
-
const latest = ratios[ratios.length - 1];
|
|
143
|
-
out.current = Number(latest[1]);
|
|
144
|
-
if (!out.target) out.target = Number(latest[2]);
|
|
145
|
-
out.source = "active_context_ratio";
|
|
146
|
-
}
|
|
147
245
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
246
|
+
// Progress is TEST-SCOPED, and ONLY test-scoped. A generic lifetime line
|
|
247
|
+
// ("LIVE, 4 SALES" — all from April) or a historical ratio ("4/10 sales" from
|
|
248
|
+
// a dead strategy) must NOT count as progress toward THIS bet; doing so
|
|
249
|
+
// fabricated "4 of 2 · complete" the moment the target became a clean 2. The
|
|
250
|
+
// ONLY thing that counts as current is an explicit scoped record whose
|
|
251
|
+
// denominator and unit family match the active target:
|
|
252
|
+
// "Current bet progress: 0/2 paying customers". [sol #11 cross-scope bug]
|
|
253
|
+
if (out.target) {
|
|
254
|
+
const scoped = text.match(/current\s+bet\s+progress:\s*(\d+)\s*\/\s*(\d+)\s*([a-z][a-z ]*)?/i);
|
|
255
|
+
if (scoped) {
|
|
256
|
+
const cur = Number(scoped[1]);
|
|
257
|
+
const den = Number(scoped[2]);
|
|
258
|
+
const scopedUnit = (scoped[3] || "").trim();
|
|
259
|
+
const unitOk = !scopedUnit || unitFamilyMatch(scopedUnit, out.unit);
|
|
260
|
+
if (Number.isFinite(cur) && den === out.target && unitOk) {
|
|
261
|
+
out.current = cur; // a scoped ZERO is honest progress, not "unmeasured"
|
|
262
|
+
out.progressMeasured = true;
|
|
263
|
+
out.source = "scoped_progress";
|
|
264
|
+
}
|
|
157
265
|
}
|
|
158
266
|
}
|
|
159
267
|
|
|
@@ -166,8 +274,12 @@ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
|
|
|
166
274
|
|
|
167
275
|
function extractDate(text, now) {
|
|
168
276
|
if (!text) return null;
|
|
169
|
-
const iso = text.match(/\b(20\d{2}-[01]\d-[0-3]\d)\b/);
|
|
170
|
-
if (iso)
|
|
277
|
+
const iso = text.match(/\b(20\d{2})-([01]\d)-([0-3]\d)\b/);
|
|
278
|
+
if (iso) {
|
|
279
|
+
// Impossible dates (2026-02-30) must yield NO deadline, not a silent
|
|
280
|
+
// JS rollover to March 2 rendered as a real target. [terra F4, 2026-07-10]
|
|
281
|
+
return validCalendarDay(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) ? iso[0] : null;
|
|
282
|
+
}
|
|
171
283
|
const months = ["jan","feb","mar","apr","may","jun","jul","aug","sep","sept","oct","nov","dec","january","february","march","april","june","july","august","september","october","november","december"];
|
|
172
284
|
const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
|
|
173
285
|
if (!m) return null;
|
|
@@ -175,9 +287,17 @@ function extractDate(text, now) {
|
|
|
175
287
|
const day = Number(m[2]);
|
|
176
288
|
const year = m[3] ? Number(m[3]) : now.getFullYear();
|
|
177
289
|
if (month < 0 || !Number.isFinite(day)) return null;
|
|
290
|
+
if (!validCalendarDay(year, month, day)) return null; // "Feb 30" rolls over too
|
|
178
291
|
return localDateKey(new Date(year, month, day));
|
|
179
292
|
}
|
|
180
293
|
|
|
294
|
+
// True only when (year, month0, day) names a day that exists: round-trip
|
|
295
|
+
// through UTC and require every component to survive unchanged.
|
|
296
|
+
function validCalendarDay(year, month0, day) {
|
|
297
|
+
const d = new Date(Date.UTC(year, month0, day));
|
|
298
|
+
return d.getUTCFullYear() === year && d.getUTCMonth() === month0 && d.getUTCDate() === day;
|
|
299
|
+
}
|
|
300
|
+
|
|
181
301
|
function monthIndex(name) {
|
|
182
302
|
const n = String(name || "").toLowerCase().slice(0, 3);
|
|
183
303
|
return { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }[n] ?? -1;
|
|
@@ -301,4 +421,7 @@ function parseChainStages(chain) {
|
|
|
301
421
|
.map((label) => ({ label, current: null }));
|
|
302
422
|
}
|
|
303
423
|
|
|
304
|
-
|
|
424
|
+
// parseGoalProgress exported for the menu-bar CLI (cmdPulseBar): the bar's
|
|
425
|
+
// mission number must come from the SAME parse the dashboard shows, never a
|
|
426
|
+
// second implementation that can drift. [owner: glance-number = the bet]
|
|
427
|
+
module.exports = { computeGoalForecast, parseGoalProgress, parseDomainProgress, classifyGoalKind };
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const {
|
|
5
5
|
archivesDir,
|
|
6
|
+
engineDir,
|
|
6
7
|
libraryDir,
|
|
7
8
|
libraryCheatsheetsDir,
|
|
8
9
|
libraryPrinciplesDir,
|
|
@@ -189,6 +190,18 @@ function indexLibraryV2(vault) {
|
|
|
189
190
|
.filter((e) => !e.name.startsWith(".") && !e.name.startsWith("_")).length;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
// Engine: count-only, top-level FILES of 02_Areas/Engine (subdirs like
|
|
194
|
+
// Dailies/ and State/ are their own worlds; the Library shelf browse lists
|
|
195
|
+
// exactly these files). Replaces a hardcoded 12 in the UI mapper — terra
|
|
196
|
+
// confirmed it fabricated (real vault held 15) and it contaminated the
|
|
197
|
+
// "indexed" headline sum (2026-07-12). Not added to totalCount, same
|
|
198
|
+
// reasoning as archives.
|
|
199
|
+
const engine = { count: 0 };
|
|
200
|
+
if (exists(engineDir(vault))) {
|
|
201
|
+
engine.count = safeListDir(engineDir(vault))
|
|
202
|
+
.filter((e) => e.isFile && !e.name.startsWith(".")).length;
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
const totalCount =
|
|
193
206
|
mocs.count + principles.count + cheatsheets.count + sops.count +
|
|
194
207
|
scripts.count + inputs.count + entities.count;
|
|
@@ -205,6 +218,7 @@ function indexLibraryV2(vault) {
|
|
|
205
218
|
inputs,
|
|
206
219
|
entities,
|
|
207
220
|
archives,
|
|
221
|
+
engine,
|
|
208
222
|
},
|
|
209
223
|
};
|
|
210
224
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// library-search.js — ONE truthful search index for the Library route.
|
|
7
|
+
//
|
|
8
|
+
// Sol full product review (2026-07-11, Library 5.0/10): the "EVERYTHING"
|
|
9
|
+
// search box indexed only the libraryV2 subset while the other shelves were
|
|
10
|
+
// counts with no rows behind them. This module makes the coverage equal the
|
|
11
|
+
// advertised shelves: every shelf the bento names is either content-searchable
|
|
12
|
+
// here or explicitly reported as notIndexable with the reason (CLI commands
|
|
13
|
+
// are usage counts, not files).
|
|
14
|
+
//
|
|
15
|
+
// Contract:
|
|
16
|
+
// - Read-only. Never writes. Never leaves the declared shelf roots.
|
|
17
|
+
// - Bounded: per-file size cap, per-shelf file cap, result cap, passage cap.
|
|
18
|
+
// - Results carry root + vault-relative (or root-relative) path + a 1-based
|
|
19
|
+
// line anchor + the matched passage, so the UI can open the real file AT
|
|
20
|
+
// the passage in the existing viewer.
|
|
21
|
+
// - Honest coverage: shelves whose directory does not exist are reported in
|
|
22
|
+
// coverage.missing; shelves with nothing to read are in
|
|
23
|
+
// coverage.notIndexable. Nothing is silently skipped.
|
|
24
|
+
// - Non-text files (PDFs, images) match by NAME only and say so
|
|
25
|
+
// (nameOnly: true, line/passage null) — never implied content coverage.
|
|
26
|
+
|
|
27
|
+
const MAX_FILE_BYTES = 256 * 1024; // matches /api/file/read cap
|
|
28
|
+
const MAX_FILES_PER_SHELF = 2000;
|
|
29
|
+
const MAX_DEPTH = 6;
|
|
30
|
+
const MAX_LIMIT = 100;
|
|
31
|
+
const DEFAULT_LIMIT = 40;
|
|
32
|
+
const PASSAGE_CAP = 240;
|
|
33
|
+
const EXCLUDED_DIRS = new Set([
|
|
34
|
+
"node_modules", "dist", ".git", ".obsidian", ".trash", "coverage",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Extensions whose CONTENT we read as text. Everything else is name-only.
|
|
38
|
+
const TEXT_EXTS = new Set([".md", ".txt", ".sh", ".js", ".mjs", ".ps1", ".canvas", ".json"]);
|
|
39
|
+
|
|
40
|
+
// ── shelf definitions — ids are stable API; labels mirror the bento ─────────
|
|
41
|
+
// dirs(ctx) returns the roots to walk for that shelf, each tagged with the
|
|
42
|
+
// serving root id the file-read endpoint understands (vault|skills|workflows|hooks).
|
|
43
|
+
const SHELVES = [
|
|
44
|
+
{
|
|
45
|
+
id: "engine", label: "Engine files",
|
|
46
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "02_Areas", "Engine"), depth: 1 }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "projects", label: "Projects",
|
|
50
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "01_Projects"), depth: MAX_DEPTH }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "library", label: "Library notes",
|
|
54
|
+
dirs: (ctx) => [
|
|
55
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "MOCs"), depth: 3 },
|
|
56
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Principles"), depth: 3 },
|
|
57
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "SOPs"), depth: 3 },
|
|
58
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Cheatsheets"), depth: 3 },
|
|
59
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Scripts"), depth: 3 },
|
|
60
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Decisions"), depth: 3 },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "skills", label: "Skills",
|
|
65
|
+
dirs: (ctx) => [{ root: "skills", dir: path.join(ctx.claudeDir, "skills"), depth: 3 }],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "hooks", label: "Hooks",
|
|
69
|
+
dirs: (ctx) => [{ root: "hooks", dir: path.join(ctx.bundleRoot, "src", "hooks"), depth: 2 }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "workflows", label: "Workflows",
|
|
73
|
+
dirs: (ctx) => [{ root: "workflows", dir: path.join(ctx.bundleRoot, "src", "workflows"), depth: 1 }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "cli", label: "CLI commands",
|
|
77
|
+
notIndexable: "CLI commands are usage counts stored in your config, not files. There is no text to search.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "people", label: "People",
|
|
81
|
+
dirs: (ctx) => [
|
|
82
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "People"), depth: 2 },
|
|
83
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Organizations"), depth: 2 },
|
|
84
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Places"), depth: 2 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "captures", label: "Captures",
|
|
89
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Inputs"), depth: 3 }],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "archives", label: "Archives",
|
|
93
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "04_Archives"), depth: MAX_DEPTH }],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Map a serving-root id to its absolute directory. Used by the file-read
|
|
98
|
+
* endpoint to resolve non-vault results with the same realpath containment
|
|
99
|
+
* it already applies to the vault. Returns null for unknown roots. */
|
|
100
|
+
function rootDirFor(ctx, root) {
|
|
101
|
+
if (root === "vault") return ctx.vault;
|
|
102
|
+
if (root === "skills") return path.join(ctx.claudeDir, "skills");
|
|
103
|
+
if (root === "workflows") return path.join(ctx.bundleRoot, "src", "workflows");
|
|
104
|
+
if (root === "hooks") return path.join(ctx.bundleRoot, "src", "hooks");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function walkFiles(dir, maxDepth, out, state) {
|
|
109
|
+
if (state.count >= MAX_FILES_PER_SHELF) return;
|
|
110
|
+
let entries;
|
|
111
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
|
|
112
|
+
for (const ent of entries) {
|
|
113
|
+
if (state.count >= MAX_FILES_PER_SHELF) return;
|
|
114
|
+
if (ent.name.startsWith(".")) continue;
|
|
115
|
+
const full = path.join(dir, ent.name);
|
|
116
|
+
if (ent.isDirectory()) {
|
|
117
|
+
if (EXCLUDED_DIRS.has(ent.name)) continue;
|
|
118
|
+
if (maxDepth > 1) walkFiles(full, maxDepth - 1, out, state);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!ent.isFile()) continue; // symlinks are not followed — containment stays real
|
|
122
|
+
out.push(full);
|
|
123
|
+
state.count += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Collect the files behind one shelf. Returns { files: [{abs, root, rootDir}], missing } */
|
|
128
|
+
function shelfFiles(shelf, ctx) {
|
|
129
|
+
const roots = shelf.dirs ? shelf.dirs(ctx) : [];
|
|
130
|
+
const files = [];
|
|
131
|
+
let anyDirExists = false;
|
|
132
|
+
const state = { count: 0 };
|
|
133
|
+
for (const r of roots) {
|
|
134
|
+
let stat = null;
|
|
135
|
+
try { stat = fs.statSync(r.dir); } catch (_e) { /* absent */ }
|
|
136
|
+
if (!stat || !stat.isDirectory()) continue;
|
|
137
|
+
anyDirExists = true;
|
|
138
|
+
const abs = [];
|
|
139
|
+
walkFiles(r.dir, r.depth || 1, abs, state);
|
|
140
|
+
for (const a of abs) files.push({ abs: a, root: r.root, rootDir: rootDirFor(ctx, r.root) });
|
|
141
|
+
}
|
|
142
|
+
return { files, missing: roots.length > 0 && !anyDirExists };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build the passage around the first match inside a line, capped. */
|
|
146
|
+
function passageFrom(line, matchIdx, qLen) {
|
|
147
|
+
const trimmed = line.trim();
|
|
148
|
+
if (trimmed.length <= PASSAGE_CAP) return trimmed;
|
|
149
|
+
// center the window on the match
|
|
150
|
+
const lead = Math.max(0, matchIdx - Math.floor((PASSAGE_CAP - qLen) / 2));
|
|
151
|
+
const slice = line.slice(lead, lead + PASSAGE_CAP).trim();
|
|
152
|
+
return slice;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* searchLibrary(ctx, opts) — the one index.
|
|
157
|
+
* ctx = { vault, bundleRoot, claudeDir }
|
|
158
|
+
* opts = { q, shelf, limit }
|
|
159
|
+
* q — case-insensitive substring; empty q + shelf = browse listing
|
|
160
|
+
* shelf — shelf id to scope to ("" = every indexed shelf)
|
|
161
|
+
* limit — max results (1..100)
|
|
162
|
+
* Returns { q, shelf, results, truncated, scanned, coverage }.
|
|
163
|
+
*/
|
|
164
|
+
function searchLibrary(ctx, opts = {}) {
|
|
165
|
+
const q = String(opts.q || "").slice(0, 200);
|
|
166
|
+
const scope = String(opts.shelf || "");
|
|
167
|
+
const limit = Math.max(1, Math.min(MAX_LIMIT, Number(opts.limit) || DEFAULT_LIMIT));
|
|
168
|
+
const lc = q.toLowerCase();
|
|
169
|
+
|
|
170
|
+
const coverage = { indexed: [], notIndexable: [], missing: [] };
|
|
171
|
+
const results = [];
|
|
172
|
+
let scannedFiles = 0;
|
|
173
|
+
let skippedLarge = 0;
|
|
174
|
+
let truncated = false;
|
|
175
|
+
|
|
176
|
+
for (const shelf of SHELVES) {
|
|
177
|
+
if (shelf.notIndexable) {
|
|
178
|
+
coverage.notIndexable.push({ id: shelf.id, label: shelf.label, reason: shelf.notIndexable });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const { files, missing } = shelfFiles(shelf, ctx);
|
|
182
|
+
if (missing) {
|
|
183
|
+
coverage.missing.push({ id: shelf.id, label: shelf.label });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
coverage.indexed.push({ id: shelf.id, label: shelf.label, files: files.length });
|
|
187
|
+
|
|
188
|
+
if (scope && scope !== shelf.id) continue; // counted for coverage, never searched
|
|
189
|
+
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
// Every skill's file is literally named SKILL.md — the skill's actual
|
|
192
|
+
// name is its folder. Rendering the basename made the whole Skills
|
|
193
|
+
// shelf read "SKILL.md, SKILL.md, ..." (owner report + terra CONFIRMED
|
|
194
|
+
// #1, 2026-07-12). Same rule for any convention-named container file.
|
|
195
|
+
const base = path.basename(f.abs);
|
|
196
|
+
let name = base;
|
|
197
|
+
if (/^(SKILL|README|INDEX)\.md$/i.test(base)) {
|
|
198
|
+
const folder = path.basename(path.dirname(f.abs));
|
|
199
|
+
if (folder && folder !== "." && folder !== path.basename(f.rootDir)) name = folder;
|
|
200
|
+
}
|
|
201
|
+
const relPath = path.relative(f.rootDir, f.abs).split(path.sep).join("/");
|
|
202
|
+
// ext/textReadable ALWAYS derive from the real file, never the display
|
|
203
|
+
// name — a folder-derived name has no extension and would silently
|
|
204
|
+
// demote the file to name-only search.
|
|
205
|
+
const ext = path.extname(base).toLowerCase();
|
|
206
|
+
const textReadable = TEXT_EXTS.has(ext);
|
|
207
|
+
|
|
208
|
+
let mtime = null;
|
|
209
|
+
let size = 0;
|
|
210
|
+
try { const st = fs.statSync(f.abs); mtime = st.mtime.toISOString(); size = st.size; } catch (_e) { continue; }
|
|
211
|
+
|
|
212
|
+
// Browse mode: empty query + a shelf scope lists the shelf's real files.
|
|
213
|
+
if (!lc) {
|
|
214
|
+
if (!scope) continue; // browsing "everything" is the catalog's job
|
|
215
|
+
results.push({
|
|
216
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
217
|
+
line: null, passage: null, matches: 0, nameOnly: !textReadable, mtime,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const nameHit = name.toLowerCase().includes(lc);
|
|
223
|
+
|
|
224
|
+
if (!textReadable) {
|
|
225
|
+
// Content is not text — a name match is the only honest match.
|
|
226
|
+
if (nameHit) {
|
|
227
|
+
results.push({
|
|
228
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
229
|
+
line: null, passage: null, matches: 0, nameOnly: true, mtime,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (size > MAX_FILE_BYTES) { skippedLarge += 1; continue; }
|
|
236
|
+
|
|
237
|
+
let content = "";
|
|
238
|
+
try { content = fs.readFileSync(f.abs, "utf8"); } catch (_e) { continue; }
|
|
239
|
+
scannedFiles += 1;
|
|
240
|
+
|
|
241
|
+
let firstLine = null;
|
|
242
|
+
let firstPassage = null;
|
|
243
|
+
let matches = 0;
|
|
244
|
+
const lines = content.split("\n");
|
|
245
|
+
for (let i = 0; i < lines.length; i++) {
|
|
246
|
+
const idx = lines[i].toLowerCase().indexOf(lc);
|
|
247
|
+
if (idx === -1) continue;
|
|
248
|
+
matches += 1;
|
|
249
|
+
if (firstLine === null) {
|
|
250
|
+
firstLine = i + 1;
|
|
251
|
+
firstPassage = passageFrom(lines[i], idx, lc.length);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!nameHit && matches === 0) continue;
|
|
256
|
+
results.push({
|
|
257
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
258
|
+
line: firstLine, passage: firstPassage, matches,
|
|
259
|
+
nameOnly: matches === 0, mtime,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Rank: name matches first, then by match density, then recency.
|
|
265
|
+
if (lc) {
|
|
266
|
+
results.sort((a, b) => {
|
|
267
|
+
const an = a.name.toLowerCase().includes(lc) ? 1 : 0;
|
|
268
|
+
const bn = b.name.toLowerCase().includes(lc) ? 1 : 0;
|
|
269
|
+
if (an !== bn) return bn - an;
|
|
270
|
+
if (a.matches !== b.matches) return b.matches - a.matches;
|
|
271
|
+
return String(b.mtime || "").localeCompare(String(a.mtime || ""));
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
results.sort((a, b) => String(b.mtime || "").localeCompare(String(a.mtime || "")));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (results.length > limit) {
|
|
278
|
+
results.length = limit;
|
|
279
|
+
truncated = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
q, shelf: scope || "all",
|
|
284
|
+
results, truncated,
|
|
285
|
+
scanned: { files: scannedFiles, skippedLarge },
|
|
286
|
+
coverage,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { searchLibrary, rootDirFor, SHELVES };
|
|
File without changes
|