@yeaft/webchat-agent 0.1.635 → 0.1.637
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/package.json +1 -1
- package/unify/compact/orchestrator.js +1 -1
- package/unify/engine.js +1 -1
- package/unify/index.js +0 -5
- package/unify/memory/dream-scheduler.js +6 -30
- package/unify/router/vp-planner.js +1 -1
- package/unify/session.js +50 -1
- package/unify/dream-v2/diff-gate.js +0 -89
- package/unify/dream-v2/refresh.js +0 -109
- package/unify/dream-v2/scope-sig.js +0 -42
- package/unify/dream-v2/tick.js +0 -99
- package/unify/memory/scope-tree.js +0 -520
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -30,7 +30,7 @@ import { archiveTurn } from './archive/turn-archive.js';
|
|
|
30
30
|
import { archiveToolResults } from './archive/tool-results.js';
|
|
31
31
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
32
32
|
import { buildUserProfile } from './memory/user-memory-store.js';
|
|
33
|
-
import { readSummary as readScopeSummary } from './memory/
|
|
33
|
+
import { readSummary as readScopeSummary } from './memory/store-v2.js';
|
|
34
34
|
import { runStopHooks } from './stop-hooks.js';
|
|
35
35
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
36
36
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
package/unify/index.js
CHANGED
|
@@ -23,15 +23,10 @@ export { Engine } from './engine.js';
|
|
|
23
23
|
export { ConversationStore, parseMessage, estimateTokens } from './conversation/persist.js';
|
|
24
24
|
export { searchMessages } from './conversation/search.js';
|
|
25
25
|
export { MemoryStore, parseEntry, serializeEntry, MEMORY_KINDS } from './memory/store.js';
|
|
26
|
-
export { recall, extractKeywords, computeFingerprint, clearRecallCache } from './memory/recall.js';
|
|
27
|
-
export { extractMemories } from './memory/extract.js';
|
|
28
|
-
export { consolidate, shouldConsolidate } from './memory/consolidate.js';
|
|
29
26
|
|
|
30
27
|
// Phase 5: Advanced features
|
|
31
28
|
export { KINDS, KIND_PRIORITY, KIND_DESCRIPTIONS, IMPORTANCE_LEVELS, validateEntry, parseScopePath, getAncestorScopes, areScopesRelated } from './memory/types.js';
|
|
32
29
|
export { scanEntries, scoreEntry, findStaleEntries, findDuplicateGroups, summarizeScan } from './memory/scan.js';
|
|
33
|
-
export { dream, checkDreamGate, readDreamState, writeDreamState, incrementQueryCount } from './memory/dream.js';
|
|
34
|
-
export { buildOrientPrompt, buildGatherPrompt, buildMergePrompt, buildPrunePrompt, buildPromotePrompt } from './memory/dream-prompt.js';
|
|
35
30
|
export { runStopHooks } from './stop-hooks.js';
|
|
36
31
|
export { MCPManager, createMCPManager } from './mcp.js';
|
|
37
32
|
export { SkillManager, createSkillManager, parseSkill, serializeSkill } from './skills.js';
|
|
@@ -24,8 +24,6 @@
|
|
|
24
24
|
import { dreamShard } from './dream-shard.js';
|
|
25
25
|
import { checkRecompression } from './recompression.js';
|
|
26
26
|
import { runUserDreamJob } from './user-memory-store.js';
|
|
27
|
-
import { runDreamTick } from '../dream-v2/tick.js';
|
|
28
|
-
import { createScopeRefreshHook } from '../dream-v2/refresh.js';
|
|
29
27
|
|
|
30
28
|
/** Default idle timeout before dream triggers (ms). */
|
|
31
29
|
export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
|
|
@@ -160,34 +158,12 @@ export function createDreamScheduler(opts = {}) {
|
|
|
160
158
|
// Non-fatal
|
|
161
159
|
}
|
|
162
160
|
|
|
163
|
-
// Phase 8 PR-F
|
|
164
|
-
// (DESIGN
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
if (memoryDir) {
|
|
170
|
-
try {
|
|
171
|
-
const scopes = [{ kind: 'user', scopeDir: 'user' }];
|
|
172
|
-
if (group?.id) scopes.push({ kind: 'group', id: group.id, scopeDir: `groups/${group.id}` });
|
|
173
|
-
// PR-J: real refresh hook — read each scope's `index.md`,
|
|
174
|
-
// render a deterministic top-N synopsis, atomically write
|
|
175
|
-
// `summary.md`. No LLM, refresh-only (DESIGN.md §8 line 395).
|
|
176
|
-
const refresh = createScopeRefreshHook({ root: memoryDir });
|
|
177
|
-
const tickResult = await runDreamTick({
|
|
178
|
-
root: memoryDir,
|
|
179
|
-
scopes,
|
|
180
|
-
refresh,
|
|
181
|
-
});
|
|
182
|
-
result.dreamV2Tick = {
|
|
183
|
-
ran: tickResult.ran.length,
|
|
184
|
-
skipped: tickResult.skipped.length,
|
|
185
|
-
errors: tickResult.errors.length,
|
|
186
|
-
};
|
|
187
|
-
} catch {
|
|
188
|
-
// Non-fatal
|
|
189
|
-
}
|
|
190
|
-
}
|
|
161
|
+
// Phase 8 PR-F dream-v2 diff-gated scope-summary refresh has been
|
|
162
|
+
// removed (DESIGN-v2 §13: replaced by 12h / manual trigger model).
|
|
163
|
+
// The new v2 dream pipeline lives in dream-v2/runner.js, wired via
|
|
164
|
+
// dream-v2/session-wiring.js when config.memoryV2 is on. This
|
|
165
|
+
// legacy scheduler only runs for memoryV2=false and is itself
|
|
166
|
+
// slated for deletion alongside the rest of the R6 stack.
|
|
191
167
|
|
|
192
168
|
messagesSinceLastDream = 0;
|
|
193
169
|
lastDreamAt = Date.now();
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* }
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { isVpForeign } from '../memory/
|
|
39
|
+
import { isVpForeign } from '../memory/store-v2.js';
|
|
40
40
|
|
|
41
41
|
/** @typedef {{ userOriginal: string, intent: string }} ForwardQuery */
|
|
42
42
|
/** @typedef {{ memoryPaths: string[], taskIds: string[] }} Preselect */
|
package/unify/session.js
CHANGED
|
@@ -37,8 +37,9 @@ import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
|
37
37
|
import { createDreamScheduler } from './memory/dream-scheduler.js';
|
|
38
38
|
import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
|
|
39
39
|
import { getUserMemoryStore } from './memory/user-memory-store.js';
|
|
40
|
+
import { migrateR6toV2 } from './memory/migrate-r6-to-v2.js';
|
|
40
41
|
import { join } from 'path';
|
|
41
|
-
import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
|
|
42
|
+
import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, writeFileSync as writeFileSyncSafe } from 'fs';
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* @typedef {Object} SessionOptions
|
|
@@ -135,6 +136,54 @@ export async function loadSession(options = {}) {
|
|
|
135
136
|
}
|
|
136
137
|
} catch { /* never let this warn path block session load */ }
|
|
137
138
|
|
|
139
|
+
// ─── 2.2 Auto-migrate R6 → v2 on first boot with memoryV2=on ──
|
|
140
|
+
// If `memoryV2` is on AND a R6-shaped tree is on disk AND we
|
|
141
|
+
// haven't already migrated, run the one-shot migration. The
|
|
142
|
+
// migration is idempotent and concatenate-don't-synthesise, so
|
|
143
|
+
// the worst case on re-run is "no R6 dirs found, exit clean".
|
|
144
|
+
// Failure here MUST NOT block session boot — we log + continue;
|
|
145
|
+
// dream will gradually backfill v2 from group diffs.
|
|
146
|
+
try {
|
|
147
|
+
if (config?.memoryV2 === true && !config?._readOnly) {
|
|
148
|
+
const memoryRoot = join(yeaftDir, 'memory');
|
|
149
|
+
const stateFile = join(yeaftDir, '.memory-v2-migration.json');
|
|
150
|
+
let alreadyMigrated = false;
|
|
151
|
+
if (existsSyncSafe(stateFile)) {
|
|
152
|
+
try {
|
|
153
|
+
const state = JSON.parse(readFileSyncSafe(stateFile, 'utf8') || '{}');
|
|
154
|
+
alreadyMigrated = Boolean(state && state.completedAt);
|
|
155
|
+
} catch { /* malformed state → re-run; migration is idempotent */ }
|
|
156
|
+
}
|
|
157
|
+
const hasR6 = existsSyncSafe(join(memoryRoot, 'groups'))
|
|
158
|
+
|| existsSyncSafe(join(memoryRoot, 'features'));
|
|
159
|
+
if (!alreadyMigrated && hasR6) {
|
|
160
|
+
console.log('[Yeaft] memoryV2 on + R6 layout detected — running one-shot migration…');
|
|
161
|
+
const result = await migrateR6toV2({ root: memoryRoot, apply: true });
|
|
162
|
+
try {
|
|
163
|
+
writeFileSyncSafe(stateFile, JSON.stringify({
|
|
164
|
+
completedAt: new Date().toISOString(),
|
|
165
|
+
migratedScopes: result.migratedScopes,
|
|
166
|
+
skippedScopes: result.skippedScopes,
|
|
167
|
+
errors: result.errors,
|
|
168
|
+
backedUpTo: result.backedUpTo,
|
|
169
|
+
}, null, 2));
|
|
170
|
+
} catch { /* state file write is best-effort */ }
|
|
171
|
+
console.log(`[Yeaft] memory v2 migration done — ${result.migratedScopes} scopes migrated, backup at ${result.backedUpTo}`);
|
|
172
|
+
} else if (!alreadyMigrated && !hasR6) {
|
|
173
|
+
// Fresh user, no R6 to migrate. Mark as done so we don't keep checking.
|
|
174
|
+
try {
|
|
175
|
+
writeFileSyncSafe(stateFile, JSON.stringify({
|
|
176
|
+
completedAt: new Date().toISOString(),
|
|
177
|
+
migratedScopes: 0,
|
|
178
|
+
note: 'no R6 layout present — fresh v2',
|
|
179
|
+
}, null, 2));
|
|
180
|
+
} catch { /* best-effort */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.warn(`[Yeaft] memory v2 migration skipped due to error: ${err?.message || err}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
138
187
|
// ─── 2a. Permission pre-check ─────────────────────────
|
|
139
188
|
// If the data dir is not writable, mark session as read-only.
|
|
140
189
|
// Persistence (conversation, memory, dream) is skipped in this mode.
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dream-v2/diff-gate.js — DESIGN.md §9.14.
|
|
3
|
-
*
|
|
4
|
-
* Hourly dream tick is cheap by default: read a per-scope cursor, check
|
|
5
|
-
* whether anything has changed since last pass, skip everything if not.
|
|
6
|
-
*
|
|
7
|
-
* The cursor is a tiny JSON file `<scopeDir>/.dream-cursor.json`:
|
|
8
|
-
*
|
|
9
|
-
* { "lastTickAt": "<ISO>", "lastSeenSig": "<opaque>" }
|
|
10
|
-
*
|
|
11
|
-
* `lastSeenSig` is whatever the caller wants to put there — typically a
|
|
12
|
-
* hash of the entries dir mtime + index.md mtime. The diff-gate doesn't
|
|
13
|
-
* compute the signature; it just compares the supplied "current" against
|
|
14
|
-
* the stored "last". That keeps signatures pluggable (mtime today, content
|
|
15
|
-
* hash later, ETag on a remote scope etc.).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { promises as fs } from 'fs';
|
|
19
|
-
import { join, dirname } from 'path';
|
|
20
|
-
|
|
21
|
-
const FILE = '.dream-cursor.json';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* @param {string} root
|
|
25
|
-
* @param {string} scopeDir
|
|
26
|
-
* @returns {string}
|
|
27
|
-
*/
|
|
28
|
-
export function cursorPath(root, scopeDir) {
|
|
29
|
-
if (!root || !scopeDir) throw new Error('cursorPath: root + scopeDir required');
|
|
30
|
-
return join(root, scopeDir, FILE);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* @param {{ root: string, scopeDir: string }} args
|
|
35
|
-
* @returns {Promise<{ lastTickAt: string|null, lastSeenSig: string|null }>}
|
|
36
|
-
*/
|
|
37
|
-
export async function readCursor({ root, scopeDir }) {
|
|
38
|
-
const path = cursorPath(root, scopeDir);
|
|
39
|
-
try {
|
|
40
|
-
const content = await fs.readFile(path, 'utf8');
|
|
41
|
-
const parsed = JSON.parse(content);
|
|
42
|
-
return {
|
|
43
|
-
lastTickAt: typeof parsed?.lastTickAt === 'string' ? parsed.lastTickAt : null,
|
|
44
|
-
lastSeenSig: typeof parsed?.lastSeenSig === 'string' ? parsed.lastSeenSig : null,
|
|
45
|
-
};
|
|
46
|
-
} catch (err) {
|
|
47
|
-
if (err && err.code === 'ENOENT') return { lastTickAt: null, lastSeenSig: null };
|
|
48
|
-
if (err instanceof SyntaxError) return { lastTickAt: null, lastSeenSig: null };
|
|
49
|
-
throw err;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{
|
|
55
|
-
* root: string,
|
|
56
|
-
* scopeDir: string,
|
|
57
|
-
* sig: string,
|
|
58
|
-
* tickAt?: string,
|
|
59
|
-
* }} args
|
|
60
|
-
*/
|
|
61
|
-
export async function writeCursor({ root, scopeDir, sig, tickAt }) {
|
|
62
|
-
if (typeof sig !== 'string') throw new Error('writeCursor: sig must be string');
|
|
63
|
-
const path = cursorPath(root, scopeDir);
|
|
64
|
-
await fs.mkdir(dirname(path), { recursive: true });
|
|
65
|
-
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
66
|
-
await fs.writeFile(tmp, JSON.stringify({
|
|
67
|
-
lastTickAt: tickAt || new Date().toISOString(),
|
|
68
|
-
lastSeenSig: sig,
|
|
69
|
-
}), 'utf8');
|
|
70
|
-
await fs.rename(tmp, path);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Diff-gate decision. Pure: takes (last, current) and returns whether
|
|
75
|
-
* dream should run. Caller decides what `currentSig` means.
|
|
76
|
-
*
|
|
77
|
-
* @param {{ lastSeenSig: string|null }} last
|
|
78
|
-
* @param {string} currentSig
|
|
79
|
-
* @returns {{ skip: boolean, reason: string }}
|
|
80
|
-
*/
|
|
81
|
-
export function shouldRunDream(last, currentSig) {
|
|
82
|
-
if (!last || last.lastSeenSig == null) {
|
|
83
|
-
return { skip: false, reason: 'no_cursor' };
|
|
84
|
-
}
|
|
85
|
-
if (last.lastSeenSig !== currentSig) {
|
|
86
|
-
return { skip: false, reason: 'diff' };
|
|
87
|
-
}
|
|
88
|
-
return { skip: true, reason: 'no_diff' };
|
|
89
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dream-v2/refresh.js — DESIGN.md §9.14 refresh hook (Phase 8 PR-J).
|
|
3
|
-
*
|
|
4
|
-
* The PR-F wire-up plumbed `runDreamTick` into the scheduler with a v1
|
|
5
|
-
* no-op refresh placeholder. PR-J makes the refresh real but stays
|
|
6
|
-
* within the §8 v1 charter — "Thin: skip pruning/demotion; refresh-only
|
|
7
|
-
* in v1, no LLM".
|
|
8
|
-
*
|
|
9
|
-
* What "refresh" does today:
|
|
10
|
-
* 1. Read the scope's `index.md` rows (the entry catalog).
|
|
11
|
-
* 2. Select the top-N most recent rows by `updated` timestamp
|
|
12
|
-
* (default 12 — enough to surface the active context without
|
|
13
|
-
* exploding Layer A token cost).
|
|
14
|
-
* 3. Render a deterministic markdown synopsis (one bullet per row:
|
|
15
|
-
* `- <title> [<kind>; tags] (<updated>)`).
|
|
16
|
-
* 4. Write to the scope's `summary.md` via `writeSummary` (atomic
|
|
17
|
-
* rename — readers never see a partial write).
|
|
18
|
-
*
|
|
19
|
-
* If the index is empty (cold-start scope), we leave `summary.md`
|
|
20
|
-
* alone. Overwriting with an empty file would erase any human-written
|
|
21
|
-
* synopsis a future tool may have placed there.
|
|
22
|
-
*
|
|
23
|
-
* No LLM call. No pruning. No tombstones. The cursor is advanced by
|
|
24
|
-
* `runDreamTick` after this hook resolves, so a clean run of refresh
|
|
25
|
-
* counts as "scope handled" and we won't re-run until the diff-gate
|
|
26
|
-
* sees new content.
|
|
27
|
-
*
|
|
28
|
-
* Errors are propagated — `runDreamTick` already catches per-scope
|
|
29
|
-
* failures and records them under `errors[]` without aborting siblings.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { readIndex, writeSummary } from '../memory/scope-tree.js';
|
|
33
|
-
|
|
34
|
-
/** Default number of recent entries surfaced on the synopsis. */
|
|
35
|
-
export const DEFAULT_TOP_N = 12;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Render a deterministic markdown synopsis for a scope from its index rows.
|
|
39
|
-
* Pure function — exported for unit tests.
|
|
40
|
-
*
|
|
41
|
-
* @param {{ kind: string, id?: string, scopeDir: string }} scope
|
|
42
|
-
* @param {Array<{ path: string, title?: string, tags?: string[]|string, kind?: string, updated?: string }>} rows
|
|
43
|
-
* @param {number} [topN]
|
|
44
|
-
* @returns {string}
|
|
45
|
-
*/
|
|
46
|
-
export function buildScopeSynopsis(scope, rows, topN = DEFAULT_TOP_N) {
|
|
47
|
-
if (!Array.isArray(rows) || rows.length === 0) return '';
|
|
48
|
-
|
|
49
|
-
// Sort by `updated` desc, then by path asc as a stable tiebreaker.
|
|
50
|
-
const sorted = [...rows].sort((a, b) => {
|
|
51
|
-
const u = (b.updated || '').localeCompare(a.updated || '');
|
|
52
|
-
return u !== 0 ? u : (a.path || '').localeCompare(b.path || '');
|
|
53
|
-
});
|
|
54
|
-
const top = sorted.slice(0, Math.max(1, topN | 0));
|
|
55
|
-
|
|
56
|
-
const header = `# ${scope.scopeDir} — recent context`;
|
|
57
|
-
const lines = top.map((r) => {
|
|
58
|
-
const title = (r.title || r.path || 'entry').trim();
|
|
59
|
-
const kind = r.kind ? `${r.kind}` : '';
|
|
60
|
-
const tagsRaw = Array.isArray(r.tags) ? r.tags : (typeof r.tags === 'string' ? r.tags.split(',') : []);
|
|
61
|
-
const tags = tagsRaw.map((t) => String(t).trim()).filter(Boolean);
|
|
62
|
-
const meta = [kind, ...tags].filter(Boolean).join('; ');
|
|
63
|
-
const metaPart = meta ? ` [${meta}]` : '';
|
|
64
|
-
const updated = r.updated ? ` (${r.updated})` : '';
|
|
65
|
-
return `- ${title}${metaPart}${updated}`;
|
|
66
|
-
});
|
|
67
|
-
return [header, '', ...lines, ''].join('\n');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Translate a `runDreamTick` ScopeRef ({ kind, id?, scopeDir }) into a
|
|
72
|
-
* `scope-tree.js` Scope ({ kind, id }). The two surfaces share the
|
|
73
|
-
* `kind` enum but `runDreamTick` carries `scopeDir` as the canonical
|
|
74
|
-
* identifier; scope-tree derives it from `kind`+`id`.
|
|
75
|
-
*
|
|
76
|
-
* @param {{ kind: string, id?: string, scopeDir: string }} ref
|
|
77
|
-
* @returns {{ kind: string, id?: string }}
|
|
78
|
-
*/
|
|
79
|
-
export function refToScope(ref) {
|
|
80
|
-
if (!ref || typeof ref !== 'object') {
|
|
81
|
-
throw new Error('refToScope: ref required');
|
|
82
|
-
}
|
|
83
|
-
if (ref.kind === 'user') return { kind: 'user' };
|
|
84
|
-
if (!ref.id) throw new Error(`refToScope: ${ref.kind} scope requires id`);
|
|
85
|
-
return { kind: ref.kind, id: ref.id };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Build a refresh hook bound to a specific memory root. The returned
|
|
90
|
-
* function is the `refresh` arg `runDreamTick` expects.
|
|
91
|
-
*
|
|
92
|
-
* @param {{ root: string, topN?: number }} args
|
|
93
|
-
* @returns {(scope: { kind: string, id?: string, scopeDir: string }) => Promise<void>}
|
|
94
|
-
*/
|
|
95
|
-
export function createScopeRefreshHook({ root, topN = DEFAULT_TOP_N } = {}) {
|
|
96
|
-
if (!root || typeof root !== 'string') {
|
|
97
|
-
throw new Error('createScopeRefreshHook: root required');
|
|
98
|
-
}
|
|
99
|
-
return async function refreshScopeSummary(scope) {
|
|
100
|
-
const target = refToScope(scope);
|
|
101
|
-
const rows = await readIndex(target, { root });
|
|
102
|
-
const body = buildScopeSynopsis(scope, rows, topN);
|
|
103
|
-
if (!body) {
|
|
104
|
-
// Cold-start scope: leave summary.md alone (see header docs).
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
await writeSummary(target, body, { root });
|
|
108
|
-
};
|
|
109
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dream-v2/scope-sig.js — DESIGN.md §9.14.
|
|
3
|
-
*
|
|
4
|
-
* Default signature for a scope dir: combine the mtimes of `entries/`,
|
|
5
|
-
* `index.md`, `summary.md` into a stable opaque string. Cheap and
|
|
6
|
-
* dependency-free — we explicitly stay out of "compute SHA over all
|
|
7
|
-
* entry bodies" territory because the dream tick is meant to be
|
|
8
|
-
* fingertip-cheap when nothing changed.
|
|
9
|
-
*
|
|
10
|
-
* Missing files contribute `0` to the signature, so cold-start scopes
|
|
11
|
-
* have a stable "empty" signature until the first entry lands.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { promises as fs } from 'fs';
|
|
15
|
-
import { join } from 'path';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {{ root: string, scopeDir: string }} args
|
|
19
|
-
* @returns {Promise<string>}
|
|
20
|
-
*/
|
|
21
|
-
export async function computeScopeSig({ root, scopeDir }) {
|
|
22
|
-
if (!root || !scopeDir) throw new Error('computeScopeSig: root + scopeDir required');
|
|
23
|
-
const targets = [
|
|
24
|
-
join(root, scopeDir, 'entries'),
|
|
25
|
-
join(root, scopeDir, 'index.md'),
|
|
26
|
-
join(root, scopeDir, 'summary.md'),
|
|
27
|
-
];
|
|
28
|
-
const stamps = [];
|
|
29
|
-
for (const p of targets) {
|
|
30
|
-
try {
|
|
31
|
-
const s = await fs.stat(p);
|
|
32
|
-
stamps.push(`${s.mtimeMs.toFixed(0)}:${s.size}`);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
if (err && err.code === 'ENOENT') {
|
|
35
|
-
stamps.push('0:0');
|
|
36
|
-
} else {
|
|
37
|
-
throw err;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return stamps.join('|');
|
|
42
|
-
}
|
package/unify/dream-v2/tick.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dream-v2/tick.js — DESIGN.md §9.14 dream cadence.
|
|
3
|
-
*
|
|
4
|
-
* Hourly tick:
|
|
5
|
-
* 1. For each registered scope, compute current signature.
|
|
6
|
-
* 2. Read the scope cursor; if no diff, skip.
|
|
7
|
-
* 3. On diff (or `force`), call the supplied `refresh(scope)` hook,
|
|
8
|
-
* which is responsible for rewriting `summary.md` / `index.md`.
|
|
9
|
-
* Any errors per scope are captured; other scopes still run.
|
|
10
|
-
* 4. Write the new cursor.
|
|
11
|
-
*
|
|
12
|
-
* Phase 6 is intentionally refresh-only (DESIGN.md §8 line 395:
|
|
13
|
-
* "Thin: skip pruning/demotion; refresh-only in v1"). The `refresh`
|
|
14
|
-
* hook gets to decide what "refresh" means; this file just sequences
|
|
15
|
-
* the diff-gated calls.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { computeScopeSig } from './scope-sig.js';
|
|
19
|
-
import { readCursor, writeCursor, shouldRunDream } from './diff-gate.js';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @typedef {{ kind: 'user'|'group'|'vp'|'task', id?: string, scopeDir: string }} ScopeRef
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @param {{
|
|
27
|
-
* root: string,
|
|
28
|
-
* scopes: ScopeRef[],
|
|
29
|
-
* refresh: (scope: ScopeRef) => Promise<void>,
|
|
30
|
-
* force?: boolean,
|
|
31
|
-
* computeSig?: (scope: ScopeRef) => Promise<string>,
|
|
32
|
-
* now?: () => string,
|
|
33
|
-
* }} args
|
|
34
|
-
* @returns {Promise<{
|
|
35
|
-
* ran: Array<{ scopeDir: string, reason: string }>,
|
|
36
|
-
* skipped: Array<{ scopeDir: string, reason: string }>,
|
|
37
|
-
* errors: Array<{ scopeDir: string, error: Error }>,
|
|
38
|
-
* }>}
|
|
39
|
-
*/
|
|
40
|
-
export async function runDreamTick({
|
|
41
|
-
root, scopes, refresh, force = false,
|
|
42
|
-
computeSig, now,
|
|
43
|
-
}) {
|
|
44
|
-
if (!root) throw new Error('runDreamTick: root required');
|
|
45
|
-
if (!Array.isArray(scopes)) throw new Error('runDreamTick: scopes array required');
|
|
46
|
-
if (typeof refresh !== 'function') throw new Error('runDreamTick: refresh fn required');
|
|
47
|
-
|
|
48
|
-
const sigOf = typeof computeSig === 'function'
|
|
49
|
-
? computeSig
|
|
50
|
-
: (s) => computeScopeSig({ root, scopeDir: s.scopeDir });
|
|
51
|
-
const stamp = typeof now === 'function' ? now : () => new Date().toISOString();
|
|
52
|
-
|
|
53
|
-
const ran = [];
|
|
54
|
-
const skipped = [];
|
|
55
|
-
const errors = [];
|
|
56
|
-
|
|
57
|
-
for (const scope of scopes) {
|
|
58
|
-
if (!scope || !scope.scopeDir) continue;
|
|
59
|
-
let sig;
|
|
60
|
-
try {
|
|
61
|
-
sig = await sigOf(scope);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
errors.push({ scopeDir: scope.scopeDir, error: err });
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
const last = await readCursor({ root, scopeDir: scope.scopeDir });
|
|
67
|
-
const decision = force
|
|
68
|
-
? { skip: false, reason: 'forced' }
|
|
69
|
-
: shouldRunDream(last, sig);
|
|
70
|
-
|
|
71
|
-
if (decision.skip) {
|
|
72
|
-
skipped.push({ scopeDir: scope.scopeDir, reason: decision.reason });
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
await refresh(scope);
|
|
78
|
-
ran.push({ scopeDir: scope.scopeDir, reason: decision.reason });
|
|
79
|
-
} catch (err) {
|
|
80
|
-
errors.push({ scopeDir: scope.scopeDir, error: err });
|
|
81
|
-
// Do NOT advance the cursor on failure — next tick should retry.
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Recompute the sig AFTER refresh in case the refresh hook itself
|
|
86
|
-
// wrote files; this is the value we want to compare against next tick.
|
|
87
|
-
let postSig;
|
|
88
|
-
try {
|
|
89
|
-
postSig = await sigOf(scope);
|
|
90
|
-
} catch {
|
|
91
|
-
postSig = sig;
|
|
92
|
-
}
|
|
93
|
-
await writeCursor({
|
|
94
|
-
root, scopeDir: scope.scopeDir, sig: postSig, tickAt: stamp(),
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { ran, skipped, errors };
|
|
99
|
-
}
|
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* memory/scope-tree.js — DESIGN.md Phase 2 (scoped memory tree).
|
|
3
|
-
*
|
|
4
|
-
* Implements the path-keyed scope tree described in DESIGN.md §2:
|
|
5
|
-
*
|
|
6
|
-
* ~/.yeaft/memory/
|
|
7
|
-
* user/
|
|
8
|
-
* summary.md — paragraph synopsis (Layer A)
|
|
9
|
-
* index.md — markdown table, path-keyed
|
|
10
|
-
* entries/<yyyy-mm-dd>-<slug>.md
|
|
11
|
-
* groups/<groupId>/ — same shape
|
|
12
|
-
* vp/<vpId>/ — same shape
|
|
13
|
-
* features/<featureId>/ — same shape, plus archive/
|
|
14
|
-
*
|
|
15
|
-
* This module is concerned ONLY with on-disk shape + atomic writes. It does
|
|
16
|
-
* NOT do any LLM work (extraction, summarisation, dream maintenance) — those
|
|
17
|
-
* are higher layers (compact-orchestrator, dream).
|
|
18
|
-
*
|
|
19
|
-
* Atomicity contract:
|
|
20
|
-
* - Every write goes via `.tmp` + rename to avoid torn reads. A reader
|
|
21
|
-
* mid-rename sees either the old or the new file, never half of either.
|
|
22
|
-
* - `createEntry()` opens with `O_EXCL` so concurrent producers cannot
|
|
23
|
-
* clobber a slug collision; the second writer surfaces `slug_exists`.
|
|
24
|
-
* - `index.md` is rewritten in full (markdown table). Partial-row append
|
|
25
|
-
* would leave a torn header on crash; the whole file is small enough
|
|
26
|
-
* that full rewrite + atomic rename is fine.
|
|
27
|
-
*
|
|
28
|
-
* Concurrency rules (DESIGN.md §9.1):
|
|
29
|
-
* - Two workers writing different entries to the same scope are safe —
|
|
30
|
-
* they write to different files, then each updates index.md via the
|
|
31
|
-
* atomic rename. Last writer wins for the index; both entries are
|
|
32
|
-
* present on disk regardless.
|
|
33
|
-
* - `summary.md` written via the same atomic rename. Workers reading it
|
|
34
|
-
* mid-write see either the previous or the next paragraph.
|
|
35
|
-
*
|
|
36
|
-
* Path discipline:
|
|
37
|
-
* - All scope paths returned by helpers are filesystem-relative paths
|
|
38
|
-
* ROOTED at the memory dir, e.g. `groups/eng/entries/2026-04-21-foo.md`.
|
|
39
|
-
* This matches DESIGN.md §1.2.1 — paths self-document.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import {
|
|
43
|
-
promises as fsp,
|
|
44
|
-
existsSync,
|
|
45
|
-
mkdirSync,
|
|
46
|
-
} from 'fs';
|
|
47
|
-
import { join, dirname } from 'path';
|
|
48
|
-
import { homedir } from 'os';
|
|
49
|
-
|
|
50
|
-
/** Default memory root. Tests override via `opts.root`. */
|
|
51
|
-
export const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
52
|
-
|
|
53
|
-
/** @typedef {'user'|'group'|'vp'|'feature'} ScopeKind */
|
|
54
|
-
/** @typedef {{kind: ScopeKind, id?: string}} Scope */
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Compute the scope's path segment relative to the memory root.
|
|
58
|
-
* `user/`, `groups/<id>/`, `vp/<id>/`, `features/<id>/`.
|
|
59
|
-
*
|
|
60
|
-
* @param {Scope} scope
|
|
61
|
-
* @returns {string}
|
|
62
|
-
*/
|
|
63
|
-
export function scopeDir(scope) {
|
|
64
|
-
if (!scope || typeof scope !== 'object') {
|
|
65
|
-
throw new Error('scopeDir: scope is required');
|
|
66
|
-
}
|
|
67
|
-
switch (scope.kind) {
|
|
68
|
-
case 'user':
|
|
69
|
-
return 'user';
|
|
70
|
-
case 'group':
|
|
71
|
-
if (!scope.id) throw new Error('scopeDir: group scope requires id');
|
|
72
|
-
return `groups/${scope.id}`;
|
|
73
|
-
case 'vp':
|
|
74
|
-
if (!scope.id) throw new Error('scopeDir: vp scope requires id');
|
|
75
|
-
return `vp/${scope.id}`;
|
|
76
|
-
case 'feature':
|
|
77
|
-
if (!scope.id) throw new Error('scopeDir: feature scope requires id');
|
|
78
|
-
return `features/${scope.id}`;
|
|
79
|
-
default:
|
|
80
|
-
throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Atomic write: temp-file + rename. Creates parent directories on demand.
|
|
86
|
-
* The rename is atomic on POSIX filesystems for paths on the same mount.
|
|
87
|
-
*
|
|
88
|
-
* @param {string} absPath
|
|
89
|
-
* @param {string} content
|
|
90
|
-
*/
|
|
91
|
-
async function atomicWrite(absPath, content) {
|
|
92
|
-
await fsp.mkdir(dirname(absPath), { recursive: true });
|
|
93
|
-
const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
94
|
-
await fsp.writeFile(tmp, content, 'utf8');
|
|
95
|
-
await fsp.rename(tmp, absPath);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Slugify a free-form title for use in a filename. Lowercase, ASCII letters
|
|
100
|
-
* + digits + dash. Empty / pathological input returns `entry`.
|
|
101
|
-
*
|
|
102
|
-
* @param {string} title
|
|
103
|
-
* @returns {string}
|
|
104
|
-
*/
|
|
105
|
-
export function slugify(title) {
|
|
106
|
-
const raw = (title || '')
|
|
107
|
-
.toString()
|
|
108
|
-
.toLowerCase()
|
|
109
|
-
.normalize('NFKD')
|
|
110
|
-
// eslint-disable-next-line no-misleading-character-class
|
|
111
|
-
.replace(/[̀-ͯ]/g, '') // strip combining marks
|
|
112
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
113
|
-
.replace(/^-+|-+$/g, '')
|
|
114
|
-
.slice(0, 60);
|
|
115
|
-
return raw || 'entry';
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Format a Date as `yyyy-mm-dd` in UTC. Used in entry filenames.
|
|
120
|
-
*
|
|
121
|
-
* @param {Date} [d=new Date()]
|
|
122
|
-
* @returns {string}
|
|
123
|
-
*/
|
|
124
|
-
export function isoDate(d = new Date()) {
|
|
125
|
-
const y = d.getUTCFullYear();
|
|
126
|
-
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
127
|
-
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
128
|
-
return `${y}-${m}-${day}`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Compute the canonical entry path for a (scope, title, date) triple.
|
|
133
|
-
*
|
|
134
|
-
* @param {Scope} scope
|
|
135
|
-
* @param {string} title
|
|
136
|
-
* @param {Date} [date]
|
|
137
|
-
* @returns {string} relative to memory root
|
|
138
|
-
*/
|
|
139
|
-
export function entryPathFor(scope, title, date) {
|
|
140
|
-
return `${scopeDir(scope)}/entries/${isoDate(date)}-${slugify(title)}.md`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ─── frontmatter ───────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Render YAML-ish frontmatter. Keys in deterministic order; string values
|
|
147
|
-
* are JSON-stringified to handle quotes / newlines safely; arrays render as
|
|
148
|
-
* `key: [a, b]`. Unknown values are skipped.
|
|
149
|
-
*
|
|
150
|
-
* @param {Record<string,*>} fm
|
|
151
|
-
* @returns {string}
|
|
152
|
-
*/
|
|
153
|
-
function renderFrontmatter(fm) {
|
|
154
|
-
if (!fm || typeof fm !== 'object') return '';
|
|
155
|
-
const order = ['title', 'kind', 'tags', 'source', 'createdAt', 'updatedAt'];
|
|
156
|
-
const seen = new Set();
|
|
157
|
-
const lines = ['---'];
|
|
158
|
-
for (const key of order) {
|
|
159
|
-
if (!(key in fm)) continue;
|
|
160
|
-
seen.add(key);
|
|
161
|
-
lines.push(renderFmLine(key, fm[key]));
|
|
162
|
-
}
|
|
163
|
-
for (const key of Object.keys(fm)) {
|
|
164
|
-
if (seen.has(key)) continue;
|
|
165
|
-
lines.push(renderFmLine(key, fm[key]));
|
|
166
|
-
}
|
|
167
|
-
lines.push('---');
|
|
168
|
-
return lines.filter(Boolean).join('\n');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function renderFmLine(key, value) {
|
|
172
|
-
if (value === null || value === undefined) return '';
|
|
173
|
-
if (Array.isArray(value)) {
|
|
174
|
-
const items = value.map(v => (typeof v === 'string' ? v : JSON.stringify(v)));
|
|
175
|
-
return `${key}: [${items.join(', ')}]`;
|
|
176
|
-
}
|
|
177
|
-
if (typeof value === 'string') {
|
|
178
|
-
// Quote when it contains anything non-trivial.
|
|
179
|
-
if (/^[\w\-./:]+$/.test(value)) return `${key}: ${value}`;
|
|
180
|
-
return `${key}: ${JSON.stringify(value)}`;
|
|
181
|
-
}
|
|
182
|
-
return `${key}: ${JSON.stringify(value)}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Parse the frontmatter block from a markdown body. Returns `{frontmatter,
|
|
187
|
-
* body}`; missing frontmatter ⇒ `frontmatter = {}` and `body` is the input.
|
|
188
|
-
* Best-effort parser — accepts the lines this module emits and a few common
|
|
189
|
-
* variants. Does NOT pull in a YAML dep.
|
|
190
|
-
*
|
|
191
|
-
* @param {string} content
|
|
192
|
-
* @returns {{ frontmatter: Record<string,*>, body: string }}
|
|
193
|
-
*/
|
|
194
|
-
export function parseEntry(content) {
|
|
195
|
-
if (typeof content !== 'string' || !content.startsWith('---')) {
|
|
196
|
-
return { frontmatter: {}, body: content || '' };
|
|
197
|
-
}
|
|
198
|
-
const end = content.indexOf('\n---', 4);
|
|
199
|
-
if (end === -1) return { frontmatter: {}, body: content };
|
|
200
|
-
const fmText = content.slice(4, end).trim();
|
|
201
|
-
const body = content.slice(end + 4).replace(/^\n+/, '');
|
|
202
|
-
const fm = {};
|
|
203
|
-
for (const rawLine of fmText.split('\n')) {
|
|
204
|
-
const line = rawLine.trim();
|
|
205
|
-
if (!line) continue;
|
|
206
|
-
const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
207
|
-
if (!m) continue;
|
|
208
|
-
const [, key, rest] = m;
|
|
209
|
-
fm[key] = parseFmValue(rest);
|
|
210
|
-
}
|
|
211
|
-
return { frontmatter: fm, body };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function parseFmValue(rest) {
|
|
215
|
-
if (rest === '') return '';
|
|
216
|
-
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
217
|
-
const inner = rest.slice(1, -1).trim();
|
|
218
|
-
if (!inner) return [];
|
|
219
|
-
return inner.split(',').map(s => {
|
|
220
|
-
const t = s.trim();
|
|
221
|
-
if (t.startsWith('"') && t.endsWith('"')) {
|
|
222
|
-
try { return JSON.parse(t); } catch { return t; }
|
|
223
|
-
}
|
|
224
|
-
return t;
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
if (rest.startsWith('"') && rest.endsWith('"')) {
|
|
228
|
-
try { return JSON.parse(rest); } catch { return rest.slice(1, -1); }
|
|
229
|
-
}
|
|
230
|
-
return rest;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ─── entries ───────────────────────────────────────────────────
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Create a new entry under (scope, title). Fails with `slug_exists` if the
|
|
237
|
-
* computed path already exists. Returns the relative path written.
|
|
238
|
-
*
|
|
239
|
-
* @param {{
|
|
240
|
-
* scope: Scope,
|
|
241
|
-
* title: string,
|
|
242
|
-
* body: string,
|
|
243
|
-
* tags?: string[],
|
|
244
|
-
* kind?: string,
|
|
245
|
-
* source?: string,
|
|
246
|
-
* date?: Date,
|
|
247
|
-
* root?: string,
|
|
248
|
-
* }} args
|
|
249
|
-
* @returns {Promise<{ path: string, abs: string }>}
|
|
250
|
-
*/
|
|
251
|
-
export async function createEntry(args) {
|
|
252
|
-
const { scope, title, body, tags, kind, source, date, root = DEFAULT_MEMORY_ROOT } = args;
|
|
253
|
-
if (!title || typeof title !== 'string') throw new Error('createEntry: title required');
|
|
254
|
-
if (typeof body !== 'string') throw new Error('createEntry: body required (string)');
|
|
255
|
-
const rel = entryPathFor(scope, title, date);
|
|
256
|
-
const abs = join(root, rel);
|
|
257
|
-
await fsp.mkdir(dirname(abs), { recursive: true });
|
|
258
|
-
const fm = renderFrontmatter({
|
|
259
|
-
title,
|
|
260
|
-
kind: kind || 'note',
|
|
261
|
-
tags: Array.isArray(tags) && tags.length ? tags : undefined,
|
|
262
|
-
source: source || undefined,
|
|
263
|
-
createdAt: isoDate(date),
|
|
264
|
-
updatedAt: isoDate(date),
|
|
265
|
-
});
|
|
266
|
-
const content = `${fm}\n\n${body.trim()}\n`;
|
|
267
|
-
// O_EXCL — fail loudly on slug collision (DESIGN.md §9.1 atomicity).
|
|
268
|
-
let handle;
|
|
269
|
-
try {
|
|
270
|
-
handle = await fsp.open(abs, 'wx');
|
|
271
|
-
} catch (err) {
|
|
272
|
-
if (err && err.code === 'EEXIST') {
|
|
273
|
-
const e = new Error('slug_exists');
|
|
274
|
-
e.code = 'slug_exists';
|
|
275
|
-
e.path = rel;
|
|
276
|
-
throw e;
|
|
277
|
-
}
|
|
278
|
-
throw err;
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
await handle.writeFile(content, 'utf8');
|
|
282
|
-
} finally {
|
|
283
|
-
await handle.close();
|
|
284
|
-
}
|
|
285
|
-
return { path: rel, abs };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Read an entry by its relative path; returns `null` if missing. Throws
|
|
290
|
-
* `acl_blocked` when the caller's `currentVpId` is given and the path is
|
|
291
|
-
* `vp/<other>/...` — the only hard ACL boundary.
|
|
292
|
-
*
|
|
293
|
-
* @param {string} relPath
|
|
294
|
-
* @param {{ root?: string, currentVpId?: string }} [opts]
|
|
295
|
-
* @returns {Promise<{ frontmatter: Record<string,*>, body: string, path: string } | null>}
|
|
296
|
-
*/
|
|
297
|
-
export async function readEntry(relPath, opts = {}) {
|
|
298
|
-
const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
|
|
299
|
-
if (!relPath || typeof relPath !== 'string') throw new Error('readEntry: relPath required');
|
|
300
|
-
if (currentVpId && isVpForeign(relPath, currentVpId)) {
|
|
301
|
-
const e = new Error('acl_blocked');
|
|
302
|
-
e.code = 'acl_blocked';
|
|
303
|
-
e.path = relPath;
|
|
304
|
-
throw e;
|
|
305
|
-
}
|
|
306
|
-
const abs = join(root, relPath);
|
|
307
|
-
let raw;
|
|
308
|
-
try {
|
|
309
|
-
raw = await fsp.readFile(abs, 'utf8');
|
|
310
|
-
} catch (err) {
|
|
311
|
-
if (err && err.code === 'ENOENT') return null;
|
|
312
|
-
throw err;
|
|
313
|
-
}
|
|
314
|
-
const { frontmatter, body } = parseEntry(raw);
|
|
315
|
-
return { frontmatter, body: body.replace(/\n+$/, ''), path: relPath };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* @returns {boolean} true iff `relPath` is `vp/<other>/...` (other ≠ currentVpId).
|
|
320
|
-
*/
|
|
321
|
-
export function isVpForeign(relPath, currentVpId) {
|
|
322
|
-
if (!relPath || !currentVpId) return false;
|
|
323
|
-
const m = /^vp\/([^/]+)\//.exec(relPath);
|
|
324
|
-
if (!m) return false;
|
|
325
|
-
return m[1] !== currentVpId;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ─── index.md ──────────────────────────────────────────────────
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Index row schema. `path` is the canonical, scope-rooted relative path.
|
|
332
|
-
* `updated` is YYYY-MM-DD UTC. `tags` is a comma-joined string for the
|
|
333
|
-
* markdown column; arrays are accepted as input and normalised.
|
|
334
|
-
*
|
|
335
|
-
* @typedef {{
|
|
336
|
-
* path: string,
|
|
337
|
-
* title: string,
|
|
338
|
-
* tags?: string | string[],
|
|
339
|
-
* kind?: string,
|
|
340
|
-
* updated?: string,
|
|
341
|
-
* }} IndexRow
|
|
342
|
-
*/
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Render the markdown table for `index.md` in a scope. Reverse-chronological
|
|
346
|
-
* — newest `updated` first — matching DESIGN.md §9.3 ("append on top").
|
|
347
|
-
*
|
|
348
|
-
* @param {Scope} scope
|
|
349
|
-
* @param {IndexRow[]} rows
|
|
350
|
-
* @returns {string}
|
|
351
|
-
*/
|
|
352
|
-
export function renderIndex(scope, rows) {
|
|
353
|
-
const dir = scopeDir(scope);
|
|
354
|
-
const sorted = [...(rows || [])].sort((a, b) => {
|
|
355
|
-
const ax = (b.updated || '').localeCompare(a.updated || '');
|
|
356
|
-
return ax !== 0 ? ax : (a.path || '').localeCompare(b.path || '');
|
|
357
|
-
});
|
|
358
|
-
const lines = [
|
|
359
|
-
`# index — ${dir}`,
|
|
360
|
-
'',
|
|
361
|
-
'| path | title | tags | kind | updated |',
|
|
362
|
-
'| ---- | ----- | ---- | ---- | ------- |',
|
|
363
|
-
];
|
|
364
|
-
for (const r of sorted) {
|
|
365
|
-
const path = (r.path || '').replace(/\|/g, '\\|');
|
|
366
|
-
const title = (r.title || '').replace(/\|/g, '\\|');
|
|
367
|
-
const tags = Array.isArray(r.tags) ? r.tags.join(',') : (r.tags || '');
|
|
368
|
-
const kind = r.kind || '';
|
|
369
|
-
const updated = r.updated || '';
|
|
370
|
-
lines.push(`| ${path} | ${title} | ${tags} | ${kind} | ${updated} |`);
|
|
371
|
-
}
|
|
372
|
-
return lines.join('\n') + '\n';
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Parse `index.md` markdown table back into an array of rows. Tolerates
|
|
377
|
-
* extra whitespace, missing optional columns, and the index header line.
|
|
378
|
-
* Unknown / malformed lines are skipped silently.
|
|
379
|
-
*
|
|
380
|
-
* @param {string} content
|
|
381
|
-
* @returns {IndexRow[]}
|
|
382
|
-
*/
|
|
383
|
-
export function parseIndex(content) {
|
|
384
|
-
if (typeof content !== 'string' || !content) return [];
|
|
385
|
-
const out = [];
|
|
386
|
-
for (const rawLine of content.split('\n')) {
|
|
387
|
-
const line = rawLine.trim();
|
|
388
|
-
if (!line.startsWith('|')) continue;
|
|
389
|
-
if (/^\|\s*-+/.test(line)) continue; // separator row
|
|
390
|
-
const cols = line.split('|').slice(1, -1).map(s => s.trim());
|
|
391
|
-
if (cols.length < 2) continue;
|
|
392
|
-
const [path, title, tags = '', kind = '', updated = ''] = cols;
|
|
393
|
-
if (!path || path === 'path') continue; // header row
|
|
394
|
-
out.push({
|
|
395
|
-
path,
|
|
396
|
-
title,
|
|
397
|
-
tags: tags ? tags.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
398
|
-
kind: kind || undefined,
|
|
399
|
-
updated: updated || undefined,
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
return out;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Read `index.md` for a scope and return the parsed rows. Missing index
|
|
407
|
-
* returns `[]` (cold-start scope).
|
|
408
|
-
*
|
|
409
|
-
* @param {Scope} scope
|
|
410
|
-
* @param {{ root?: string }} [opts]
|
|
411
|
-
* @returns {Promise<IndexRow[]>}
|
|
412
|
-
*/
|
|
413
|
-
export async function readIndex(scope, opts = {}) {
|
|
414
|
-
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
415
|
-
const abs = join(root, scopeDir(scope), 'index.md');
|
|
416
|
-
let raw;
|
|
417
|
-
try { raw = await fsp.readFile(abs, 'utf8'); }
|
|
418
|
-
catch (err) {
|
|
419
|
-
if (err && err.code === 'ENOENT') return [];
|
|
420
|
-
throw err;
|
|
421
|
-
}
|
|
422
|
-
return parseIndex(raw);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Atomically rewrite `index.md` for a scope.
|
|
427
|
-
*
|
|
428
|
-
* @param {Scope} scope
|
|
429
|
-
* @param {IndexRow[]} rows
|
|
430
|
-
* @param {{ root?: string }} [opts]
|
|
431
|
-
*/
|
|
432
|
-
export async function writeIndex(scope, rows, opts = {}) {
|
|
433
|
-
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
434
|
-
const abs = join(root, scopeDir(scope), 'index.md');
|
|
435
|
-
await atomicWrite(abs, renderIndex(scope, rows));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Upsert a single row keyed by `path`. Existing row with the same path is
|
|
440
|
-
* replaced; otherwise the row is prepended (kept in reverse-chronological
|
|
441
|
-
* order via `renderIndex`'s sort). Returns the updated row list.
|
|
442
|
-
*
|
|
443
|
-
* @param {Scope} scope
|
|
444
|
-
* @param {IndexRow} row
|
|
445
|
-
* @param {{ root?: string }} [opts]
|
|
446
|
-
* @returns {Promise<IndexRow[]>}
|
|
447
|
-
*/
|
|
448
|
-
export async function upsertIndexRow(scope, row, opts = {}) {
|
|
449
|
-
if (!row || !row.path) throw new Error('upsertIndexRow: row.path required');
|
|
450
|
-
const rows = await readIndex(scope, opts);
|
|
451
|
-
const filtered = rows.filter(r => r.path !== row.path);
|
|
452
|
-
filtered.unshift(row);
|
|
453
|
-
await writeIndex(scope, filtered, opts);
|
|
454
|
-
return filtered;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Cap the rows surfaced to the router (DESIGN.md §9.3). Reverse-chrono;
|
|
459
|
-
* default K = 200. Caller passes the merged set; we trim and return.
|
|
460
|
-
*
|
|
461
|
-
* @param {IndexRow[]} rows
|
|
462
|
-
* @param {number} [k=200]
|
|
463
|
-
* @returns {IndexRow[]}
|
|
464
|
-
*/
|
|
465
|
-
export function capIndexRows(rows, k = 200) {
|
|
466
|
-
if (!Array.isArray(rows)) return [];
|
|
467
|
-
if (rows.length <= k) return rows.slice();
|
|
468
|
-
return rows.slice(0, k);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ─── summary.md ────────────────────────────────────────────────
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Read the scope's `summary.md`. Empty / missing → ''.
|
|
475
|
-
*
|
|
476
|
-
* @param {Scope} scope
|
|
477
|
-
* @param {{ root?: string }} [opts]
|
|
478
|
-
* @returns {Promise<string>}
|
|
479
|
-
*/
|
|
480
|
-
export async function readSummary(scope, opts = {}) {
|
|
481
|
-
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
482
|
-
const abs = join(root, scopeDir(scope), 'summary.md');
|
|
483
|
-
try { return (await fsp.readFile(abs, 'utf8')).trim(); }
|
|
484
|
-
catch (err) {
|
|
485
|
-
if (err && err.code === 'ENOENT') return '';
|
|
486
|
-
throw err;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Atomically rewrite the scope's `summary.md`. The body is trimmed; empty
|
|
492
|
-
* input writes an empty file (callers that wanted "delete summary" can use
|
|
493
|
-
* `fs.unlink` directly — we don't surface that here).
|
|
494
|
-
*
|
|
495
|
-
* @param {Scope} scope
|
|
496
|
-
* @param {string} body
|
|
497
|
-
* @param {{ root?: string }} [opts]
|
|
498
|
-
*/
|
|
499
|
-
export async function writeSummary(scope, body, opts = {}) {
|
|
500
|
-
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
501
|
-
const abs = join(root, scopeDir(scope), 'summary.md');
|
|
502
|
-
await atomicWrite(abs, `${(body || '').trim()}\n`);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ─── ensure scope on disk ──────────────────────────────────────
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Best-effort: ensure the scope's directory and an empty `entries/` exist.
|
|
509
|
-
* Idempotent. Use at boot or on first write to avoid ENOENT cascades.
|
|
510
|
-
*
|
|
511
|
-
* @param {Scope} scope
|
|
512
|
-
* @param {{ root?: string }} [opts]
|
|
513
|
-
*/
|
|
514
|
-
export function ensureScopeSync(scope, opts = {}) {
|
|
515
|
-
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
516
|
-
const dir = join(root, scopeDir(scope));
|
|
517
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
518
|
-
const entries = join(dir, 'entries');
|
|
519
|
-
if (!existsSync(entries)) mkdirSync(entries, { recursive: true });
|
|
520
|
-
}
|