memoryintel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +192 -0
  5. package/dist/adapters/claudeCode.js +96 -0
  6. package/dist/adapters/genericPointer.js +39 -0
  7. package/dist/cli.js +157 -0
  8. package/dist/commands/daemonStart.js +8 -0
  9. package/dist/commands/dashboardToggle.js +17 -0
  10. package/dist/commands/init.js +111 -0
  11. package/dist/commands/load.js +82 -0
  12. package/dist/commands/status.js +24 -0
  13. package/dist/commands/update.js +108 -0
  14. package/dist/core/atomicWrite.js +6 -0
  15. package/dist/core/compressionConfig.js +37 -0
  16. package/dist/core/discovery.js +14 -0
  17. package/dist/core/eventLog.js +4 -0
  18. package/dist/core/gitPorcelain.js +45 -0
  19. package/dist/core/headingMatch.js +44 -0
  20. package/dist/core/lock.js +67 -0
  21. package/dist/core/memoryIndex.js +19 -0
  22. package/dist/core/pathSafety.js +43 -0
  23. package/dist/core/sectionWriter.js +91 -0
  24. package/dist/core/toon.js +118 -0
  25. package/dist/daemon/daemonHandle.js +52 -0
  26. package/dist/daemon/globalPaths.js +15 -0
  27. package/dist/daemon/health.js +14 -0
  28. package/dist/daemon/lifecycle.js +54 -0
  29. package/dist/daemon/registry.js +60 -0
  30. package/dist/daemon/server.js +92 -0
  31. package/dist/daemon/settings.js +13 -0
  32. package/dist/daemon/views/layout.js +233 -0
  33. package/dist/daemon/views/projectPage.js +111 -0
  34. package/dist/daemon/views/registryPage.js +54 -0
  35. package/dist/skill.js +46 -0
  36. package/dist/templates/starterFiles.js +22 -0
  37. package/hooks/hooks.json +11 -0
  38. package/package.json +52 -0
  39. package/skills/memoryintel/SKILL.md +55 -0
@@ -0,0 +1,111 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { STARTER_FILES, MENTAL_MODEL_STARTER } from '../templates/starterFiles.js';
4
+ import { installPointerAdapters } from '../adapters/genericPointer.js';
5
+ const INSTRUCTIONS_TEMPLATE = `# Memory Intel Instructions
6
+
7
+ This project uses Memory Intel. Read this file at the start of every session.
8
+
9
+ ## Session start
10
+ Run \`memoryintel load [--domain technical|business|research]\` and treat its output as project context.
11
+ Its manifest reports each loaded file's \`lines\`, \`ceiling\`, and \`status\` (\`over\`/\`under\`) — see
12
+ "Compaction" below for what to do about a file marked \`over\`.
13
+
14
+ ## Session end
15
+ If your work changed project understanding (new architecture, feature, decision, integration, or
16
+ roadmap item — not formatting/typos/comments), draft an update-plan (TOON table: file, action,
17
+ section, content, reason), write it to a file, and run \`memoryintel update <plan-file>\` — e.g.
18
+ \`memoryintel update /tmp/plan.toon\`. Running \`memoryintel update\` with no plan-file argument and
19
+ nothing piped to stdin fails (there is no plan to apply). Reuse exact existing heading names from
20
+ the manifest \`load\` gave you. If nothing meaningful changed, do nothing — do not call \`update\`.
21
+
22
+ Also include a row for \`context/currentMentalModel.md\` whenever the update is more than a small,
23
+ localized fact — anything that shifts what the project *is* or where it currently stands (not
24
+ every single decision/progress entry needs one). Unlike every other file, it is a **whole-file
25
+ replace**: rewrite the entire current-understanding narrative from scratch each time, in plain
26
+ prose, not another append-only log. This is the file the dashboard's "Current understanding"
27
+ section renders directly — a stale or never-written one is the single most common way this
28
+ project's memory looks broken to a human glancing at the dashboard, even when every other file is
29
+ being updated correctly.
30
+
31
+ Before adding a new \`context/decisions.md\` entry, skim the existing log for one your change
32
+ supersedes or resolves (e.g. a decision your new work reverses, or a "known limitation" it just
33
+ fixed). \`decisions.md\` is append-only by design — there is no mechanism to edit an old entry in
34
+ place — so note the resolution explicitly in the new entry ("supersedes the earlier decision to
35
+ X — see git history for why") rather than leaving the old one standing as if still current. Found
36
+ on a real project: a "known limitation, deliberately left unfixed" entry was still sitting in
37
+ \`decisions.md\` well after a later change fixed exactly that limitation — correctly recorded in
38
+ \`currentMentalModel.md\`, but never reconciled against the older, now-wrong decisions entry right
39
+ next to it.
40
+
41
+ ## Compaction
42
+ A file marked \`status: over\` in \`load\`'s manifest has grown past its configured line ceiling.
43
+ This is a signal, not a command — compact it only when it's a sensible moment to (the same
44
+ judgment you already apply to whether to update at all), by adding a row to your update-plan with
45
+ one extra field, \`kind: compress\`, and \`action: replace\` against the section that's grown large.
46
+ \`update\` will only apply that row if the target file is currently git-clean — if it isn't, the row
47
+ is rejected and the file is left untouched; commit the current state first, then retry. Aim to
48
+ compact to comfortably under the ceiling, not exactly at it.
49
+
50
+ git is the archive: nothing is duplicated into a second file. What you cut is still fully
51
+ recoverable from git history — it just won't be loaded by default anymore. Because of that:
52
+
53
+ - **Keep verbatim, never compress away:** architecture decisions and their rationale, unresolved
54
+ open questions, anything a future session would need to avoid repeating a mistake or
55
+ re-deriving a conclusion already reached.
56
+ - **Safe to compress:** resolved narrative ("we tried X, it didn't work, we did Y instead"
57
+ collapses to "Y (not X — see git history for why)"), routine progress entries fully superseded
58
+ by a later one, verbose detail a terser statement of the current state already covers.
59
+ - **Rule of thumb:** if a future session would reasonably ask "why is it built this way?" and
60
+ your summary can't answer, you compressed too much — keep more.
61
+
62
+ The ceiling itself is configurable in \`memory-config.json\` under a \`compression\` key
63
+ (\`defaultCeilingLines\`, and optional \`domainOverrides\` keyed by domain, e.g. \`"technical": 500\`)
64
+ — the built-in default is 300 lines if unset.
65
+
66
+ ## Dashboard
67
+ If the user asks to turn off the dashboard/web UI, run \`memoryintel dashboard disable\`. This is a
68
+ single shared dashboard for every Memory Intel project on this machine — tell the user it affects
69
+ all of their projects, not just this one. \`memoryintel dashboard enable\` turns it back on.
70
+ `;
71
+ function ensureFile(path, content) {
72
+ if (existsSync(path))
73
+ return;
74
+ mkdirSync(dirname(path), { recursive: true });
75
+ writeFileSync(path, content);
76
+ }
77
+ export function runInit(targetDir) {
78
+ const root = join(targetDir, '.memoryintel');
79
+ mkdirSync(root, { recursive: true });
80
+ ensureFile(join(root, 'instructions.md'), INSTRUCTIONS_TEMPLATE);
81
+ ensureFile(join(root, 'memory-config.json'), JSON.stringify({ initializedAt: new Date().toISOString(), version: '0.1.0' }, null, 2) + '\n');
82
+ ensureFile(join(root, 'memory-index.json'), '{}\n');
83
+ ensureFile(join(root, 'memory-events.jsonl'), '');
84
+ ensureFile(join(root, 'context', 'currentMentalModel.md'), MENTAL_MODEL_STARTER);
85
+ for (const file of STARTER_FILES) {
86
+ const content = file.headings.map((h) => `## ${h}\n`).join('\n');
87
+ ensureFile(join(root, file.relPath), content);
88
+ }
89
+ // No intelligence/*.json scaffolding here: those files back the V2 (semantic retrieval) /
90
+ // V3 (knowledge graph) roadmap items, which stayed permanently dropped (see prd.md's "Future
91
+ // roadmap" section) - `update` has never accepted a write to that path (it's not in
92
+ // WRITABLE_FILES) and nothing else in this codebase reads it. Confirmed dead weight on a real
93
+ // project (distilled-docs): all three files sat at literal `{}` for its entire build. Creating
94
+ // files for a permanently-shelved feature just reads as broken/confusing to find later.
95
+ // Claude Code automation comes from the memoryintel plugin's own hooks/hooks.json (global,
96
+ // active for every project once the plugin is installed) — init never touches .claude/settings.json.
97
+ // The pointer-file adapter still runs here for tools with no plugin-hook equivalent (Cursor,
98
+ // Codex, Gemini CLI, opencode, pi). It touches foreign tools' config files, which this project
99
+ // does not own and cannot assume is well-formed — a failure there must never abort init's own
100
+ // job of scaffolding .memoryintel/. Warn and carry on.
101
+ runAdapter('install pointer-file adapters', () => installPointerAdapters(targetDir));
102
+ }
103
+ function runAdapter(description, fn) {
104
+ try {
105
+ fn();
106
+ }
107
+ catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ process.stderr.write(`Warning: could not ${description}: ${message}\n`);
110
+ }
111
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { findMemoryIntelRoot } from '../core/discovery.js';
4
+ import { extractHeadings } from '../core/headingMatch.js';
5
+ import { encodeToonTable } from '../core/toon.js';
6
+ import { getCeilingLines, countLines } from '../core/compressionConfig.js';
7
+ import { ensureDaemonRunning } from '../daemon/lifecycle.js';
8
+ import { upsertRegistryEntry } from '../daemon/registry.js';
9
+ import { appendEvent } from '../core/eventLog.js';
10
+ const ALWAYS_LOAD = ['context/currentMentalModel.md', 'context/activeContext.md'];
11
+ const DOMAIN_FILES = {
12
+ technical: ['technical/architecture.md', 'technical/techContext.md', 'technical/patterns.md'],
13
+ business: ['business/productContext.md', 'business/roadmap.md', 'business/stakeholders.md'],
14
+ research: ['research/findings.md', 'research/hypotheses.md']
15
+ };
16
+ export class UnknownDomainError extends Error {
17
+ domain;
18
+ constructor(domain) {
19
+ super(`Unknown domain "${domain}". Valid domains are: ${Object.keys(DOMAIN_FILES).join(', ')}.`);
20
+ this.domain = domain;
21
+ this.name = 'UnknownDomainError';
22
+ }
23
+ }
24
+ function assertKnownDomain(domain) {
25
+ if (!Object.prototype.hasOwnProperty.call(DOMAIN_FILES, domain)) {
26
+ throw new UnknownDomainError(domain);
27
+ }
28
+ }
29
+ export function runLoad(cwd, domain) {
30
+ // Validate before touching disk so a bad --domain always produces a clear, named error
31
+ // rather than spreading `undefined` out of DOMAIN_FILES.
32
+ if (domain !== undefined)
33
+ assertKnownDomain(domain);
34
+ const root = findMemoryIntelRoot(cwd);
35
+ if (!root)
36
+ return '';
37
+ try {
38
+ ensureDaemonRunning();
39
+ upsertRegistryEntry(dirname(root));
40
+ }
41
+ catch {
42
+ // Dashboard visibility is best-effort — never let it break `load`.
43
+ }
44
+ const files = [...ALWAYS_LOAD, ...(domain ? DOMAIN_FILES[domain] : [])];
45
+ const sections = [];
46
+ const manifestRows = [];
47
+ for (const relFile of files) {
48
+ const absPath = join(root, relFile);
49
+ if (!existsSync(absPath))
50
+ continue;
51
+ const content = readFileSync(absPath, 'utf-8');
52
+ const lines = countLines(content);
53
+ const ceiling = getCeilingLines(root, relFile);
54
+ const status = lines > ceiling ? 'over' : 'under';
55
+ sections.push(`--- FILE: ${relFile} ---\n${content}`);
56
+ manifestRows.push({
57
+ file: relFile,
58
+ headings: extractHeadings(content).join('|'),
59
+ lines: String(lines),
60
+ ceiling: String(ceiling),
61
+ status
62
+ });
63
+ }
64
+ try {
65
+ const totalChars = sections.reduce((sum, s) => sum + s.length, 0);
66
+ const totalLines = manifestRows.reduce((sum, r) => sum + Number(r.lines), 0);
67
+ appendEvent(join(root, 'memory-events.jsonl'), {
68
+ timestamp: new Date().toISOString(),
69
+ type: 'session-load',
70
+ summary: `Loaded ${manifestRows.length} file(s)${domain ? ` (domain: ${domain})` : ''}`,
71
+ affectedFiles: manifestRows.map((r) => r.file),
72
+ domain: domain ?? null,
73
+ totalChars,
74
+ totalLines
75
+ });
76
+ }
77
+ catch {
78
+ // KPI telemetry is best-effort - never let logging a load break the load itself.
79
+ }
80
+ const manifest = encodeToonTable(manifestRows);
81
+ return `${manifest}\n${sections.join('\n')}`;
82
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readIndex } from '../core/memoryIndex.js';
4
+ export function runStatus(root) {
5
+ const lines = [];
6
+ const mentalModelPath = join(root, 'context', 'currentMentalModel.md');
7
+ lines.push('=== Current Mental Model ===');
8
+ lines.push(existsSync(mentalModelPath) ? readFileSync(mentalModelPath, 'utf-8').trim() : '(none)');
9
+ lines.push('', '=== Memory Index ===');
10
+ const index = readIndex(join(root, 'memory-index.json'));
11
+ for (const [file, entry] of Object.entries(index)) {
12
+ lines.push(`${file}: ${entry.summary} (updated ${entry.lastUpdated})`);
13
+ }
14
+ lines.push('', '=== Recent Events ===');
15
+ const eventsPath = join(root, 'memory-events.jsonl');
16
+ if (existsSync(eventsPath)) {
17
+ const eventLines = readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
18
+ for (const line of eventLines.slice(-5)) {
19
+ const event = JSON.parse(line);
20
+ lines.push(`[${event.timestamp}] ${event.type}: ${event.summary}`);
21
+ }
22
+ }
23
+ return lines.join('\n') + '\n';
24
+ }
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, posix, dirname } from 'node:path';
3
+ import { decodeToonTable } from '../core/toon.js';
4
+ import { applySectionUpdate, isNearDuplicate, getSectionContent } from '../core/sectionWriter.js';
5
+ import { assertSafePath } from '../core/pathSafety.js';
6
+ import { upsertIndexEntry } from '../core/memoryIndex.js';
7
+ import { appendEvent } from '../core/eventLog.js';
8
+ import { atomicWriteFile } from '../core/atomicWrite.js';
9
+ import { withLock } from '../core/lock.js';
10
+ import { ensureDaemonRunning } from '../daemon/lifecycle.js';
11
+ import { upsertRegistryEntry } from '../daemon/registry.js';
12
+ import { resolveCheckStopMarker } from '../adapters/claudeCode.js';
13
+ import { isPathClean } from '../core/gitPorcelain.js';
14
+ const MENTAL_MODEL_FILE = 'context/currentMentalModel.md';
15
+ export async function runUpdate(root, planText) {
16
+ try {
17
+ ensureDaemonRunning();
18
+ upsertRegistryEntry(dirname(root));
19
+ }
20
+ catch {
21
+ // Dashboard visibility is best-effort — never let it break `update`.
22
+ }
23
+ const rows = decodeToonTable(planText);
24
+ return withLock(join(root, '.lock'), () => {
25
+ // Phase 1: validate every entry against current disk state, compute the writes, write nothing yet.
26
+ const writes = [];
27
+ // Tracks each path's content as computed so far *this call*, so a second row targeting a
28
+ // path already touched by an earlier row builds on that row's result instead of the
29
+ // original on-disk content (which would otherwise silently discard the earlier edit).
30
+ const workingContent = new Map();
31
+ for (const row of rows) {
32
+ const absPath = assertSafePath(root, row.file);
33
+ const currentContent = workingContent.has(absPath) ? workingContent.get(absPath) : readFileSync(absPath, 'utf-8');
34
+ const isCompression = row.kind === 'compress';
35
+ // A compression row rewrites/collapses content whose only durable record, once
36
+ // compressed, is git history — so it may only proceed once the pre-compression version
37
+ // is already a real commit. This check is scoped to this row's own file, never the whole
38
+ // working tree, and treats "cannot determine git status at all" as unsafe, not as clean.
39
+ if (isCompression) {
40
+ // git always reports porcelain paths with forward slashes, regardless of OS - path.join
41
+ // here would produce a backslash-separated path on Windows that can never match, making
42
+ // every file look permanently clean there. path.posix.join keeps this comparable to
43
+ // git's own output on every platform. Caught by CI's Windows matrix job.
44
+ const clean = isPathClean(dirname(root), posix.join('.memoryintel', row.file));
45
+ if (clean !== true) {
46
+ const reason = clean === null
47
+ ? `Could not verify git status for ${row.file} — compression skipped this run.`
48
+ : `${row.file} has uncommitted changes — commit the current state before compressing it, then retry.`;
49
+ writes.push({ absPath, relFile: row.file, newContent: currentContent, reason, skipped: true, eventType: 'compression-rejected' });
50
+ continue;
51
+ }
52
+ }
53
+ if (row.file === MENTAL_MODEL_FILE) {
54
+ const skipped = currentContent.trim() === row.content.trim();
55
+ const newContent = skipped ? currentContent : row.content;
56
+ workingContent.set(absPath, newContent);
57
+ writes.push({
58
+ absPath, relFile: row.file, newContent, reason: row.reason, skipped,
59
+ eventType: skipped ? 'skipped-duplicate' : (isCompression ? 'compression' : 'memory-update')
60
+ });
61
+ continue;
62
+ }
63
+ const updated = applySectionUpdate(currentContent, row.section, row.action, row.content);
64
+ const sectionContent = getSectionContent(currentContent, row.section);
65
+ // The duplicate check only makes sense for additive writes. A 'replace' is an explicit,
66
+ // full restatement of the section — narrowing "Uses Postgres 14 and Redis" down to
67
+ // "Uses Postgres 14" must be applied, even though the new text is a substring of the old.
68
+ // ('create-section' that degrades to an append is covered here too; on a genuinely new
69
+ // section there is no existing content, so the check can never fire.)
70
+ const skipped = row.action !== 'replace' && isNearDuplicate(sectionContent ?? '', row.content);
71
+ const newContent = skipped ? currentContent : updated;
72
+ workingContent.set(absPath, newContent);
73
+ writes.push({
74
+ absPath, relFile: row.file, newContent, reason: row.reason, skipped,
75
+ eventType: skipped ? 'skipped-duplicate' : (isCompression ? 'compression' : 'memory-update')
76
+ });
77
+ }
78
+ // Phase 2: apply. Every entry above already validated, so this cannot fail on content grounds.
79
+ const applied = [];
80
+ const skipped = [];
81
+ for (const w of writes) {
82
+ if (w.skipped) {
83
+ // A dropped write is still a fact about this session — log it so `status` can show
84
+ // that the agent proposed something and it was deduplicated (or, for compression,
85
+ // rejected) rather than applied (spec §4).
86
+ appendEvent(join(root, 'memory-events.jsonl'), {
87
+ timestamp: new Date().toISOString(),
88
+ type: w.eventType,
89
+ summary: w.reason,
90
+ affectedFiles: [w.relFile]
91
+ });
92
+ skipped.push(w.relFile);
93
+ continue;
94
+ }
95
+ atomicWriteFile(w.absPath, w.newContent);
96
+ upsertIndexEntry(join(root, 'memory-index.json'), w.relFile, w.reason);
97
+ appendEvent(join(root, 'memory-events.jsonl'), {
98
+ timestamp: new Date().toISOString(),
99
+ type: w.eventType,
100
+ summary: w.reason,
101
+ affectedFiles: [w.relFile]
102
+ });
103
+ applied.push(w.relFile);
104
+ }
105
+ resolveCheckStopMarker(root);
106
+ return { applied, skipped };
107
+ });
108
+ }
@@ -0,0 +1,6 @@
1
+ import { writeFileSync, renameSync } from 'node:fs';
2
+ export function atomicWriteFile(path, content) {
3
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
4
+ writeFileSync(tempPath, content);
5
+ renameSync(tempPath, path);
6
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const DEFAULT_CEILING_LINES = 300;
4
+ // Reads memory-config.json's optional `compression` block. Missing file, missing key, or
5
+ // corrupt JSON all fall back to an empty config (which getCeilingLines then resolves to the
6
+ // built-in default) — this is a read-time convenience for load()/the dashboard, never a place
7
+ // that should throw and interrupt them.
8
+ function readCompressionConfig(root) {
9
+ const configPath = join(root, 'memory-config.json');
10
+ if (!existsSync(configPath))
11
+ return {};
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
14
+ if (parsed && typeof parsed === 'object' && parsed.compression && typeof parsed.compression === 'object') {
15
+ return parsed.compression;
16
+ }
17
+ return {};
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ export function countLines(content) {
24
+ return content.length === 0 ? 0 : content.split('\n').length;
25
+ }
26
+ // relFile's first path segment (e.g. "technical" from "technical/architecture.md", or "context"
27
+ // from "context/activeContext.md") is the domain domainOverrides keys against.
28
+ export function getCeilingLines(root, relFile) {
29
+ const config = readCompressionConfig(root);
30
+ const domain = relFile.split('/')[0];
31
+ const override = config.domainOverrides?.[domain];
32
+ if (typeof override === 'number')
33
+ return override;
34
+ if (typeof config.defaultCeilingLines === 'number')
35
+ return config.defaultCeilingLines;
36
+ return DEFAULT_CEILING_LINES;
37
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, dirname, parse } from 'node:path';
3
+ export function findMemoryIntelRoot(startDir) {
4
+ let dir = startDir;
5
+ const { root } = parse(dir);
6
+ while (true) {
7
+ const candidate = join(dir, '.memoryintel');
8
+ if (existsSync(candidate))
9
+ return candidate;
10
+ if (dir === root)
11
+ return null;
12
+ dir = dirname(dir);
13
+ }
14
+ }
@@ -0,0 +1,4 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ export function appendEvent(eventsPath, event) {
3
+ appendFileSync(eventsPath, JSON.stringify(event) + '\n');
4
+ }
@@ -0,0 +1,45 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ // Returns the raw, non-empty `git status --porcelain` lines for cwd, in the order git reports
3
+ // them, or null if this isn't a git repository / git failed for any reason. Each line keeps its
4
+ // fixed two-character status code + space prefix intact — callers must use porcelainPath (a
5
+ // fixed-offset slice) rather than trimming first, since trimming shifts that offset differently
6
+ // depending on whether the status code itself starts with a space.
7
+ export function runGitStatusPorcelain(cwd) {
8
+ try {
9
+ const output = execFileSync('git', ['status', '--porcelain'], {
10
+ cwd,
11
+ encoding: 'utf-8',
12
+ stdio: ['ignore', 'pipe', 'ignore']
13
+ });
14
+ return output.split('\n').filter((l) => l.length > 0);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function porcelainPath(line) {
21
+ return line.slice(3);
22
+ }
23
+ // Returns the current commit HEAD points at, or null if this isn't a git repository, git
24
+ // failed, or there are no commits yet (a freshly `git init`'d repo has no HEAD to resolve).
25
+ export function runGitRevParseHead(cwd) {
26
+ try {
27
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
28
+ cwd,
29
+ encoding: 'utf-8',
30
+ stdio: ['ignore', 'pipe', 'ignore']
31
+ }).trim();
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ // True if `relPath` (relative to `cwd`, the same way git itself reports paths when invoked with
38
+ // that cwd) has no uncommitted changes, false if it does, or null if git status could not be
39
+ // determined at all — callers must treat null as "cannot verify", never as clean.
40
+ export function isPathClean(cwd, relPath) {
41
+ const lines = runGitStatusPorcelain(cwd);
42
+ if (lines === null)
43
+ return null;
44
+ return !lines.some((l) => porcelainPath(l) === relPath);
45
+ }
@@ -0,0 +1,44 @@
1
+ export function normalizeHeading(s) {
2
+ return s.trim().toLowerCase().replace(/\s+/g, ' ');
3
+ }
4
+ export function extractHeadings(markdown) {
5
+ const headings = [];
6
+ for (const line of markdown.split('\n')) {
7
+ const match = /^##[ \t]+(.+?)\s*$/.exec(line);
8
+ if (match)
9
+ headings.push(match[1].trim());
10
+ }
11
+ return headings;
12
+ }
13
+ export function findHeadingMatch(headings, target) {
14
+ const normalizedTarget = normalizeHeading(target);
15
+ return headings.find((h) => normalizeHeading(h) === normalizedTarget) ?? null;
16
+ }
17
+ // Token-overlap similarity: fraction of the smaller token set contained in the larger one,
18
+ // plus a prefix-containment bonus so "Auth" vs "Authentication" scores well.
19
+ function similarity(a, b) {
20
+ const na = normalizeHeading(a);
21
+ const nb = normalizeHeading(b);
22
+ if (na === nb)
23
+ return 1;
24
+ if (na.includes(nb) || nb.includes(na))
25
+ return 0.85;
26
+ const tokensA = new Set(na.split(' '));
27
+ const tokensB = new Set(nb.split(' '));
28
+ const [small, large] = tokensA.size <= tokensB.size ? [tokensA, tokensB] : [tokensB, tokensA];
29
+ let shared = 0;
30
+ for (const t of small)
31
+ if (large.has(t))
32
+ shared++;
33
+ return small.size === 0 ? 0 : shared / small.size;
34
+ }
35
+ const SUGGESTION_THRESHOLD = 0.6;
36
+ export function suggestHeading(headings, target) {
37
+ let best = null;
38
+ for (const h of headings) {
39
+ const score = similarity(h, target);
40
+ if (!best || score > best.score)
41
+ best = { heading: h, score };
42
+ }
43
+ return best && best.score >= SUGGESTION_THRESHOLD ? best.heading : null;
44
+ }
@@ -0,0 +1,67 @@
1
+ import { openSync, closeSync, unlinkSync, constants } from 'node:fs';
2
+ function sleep(ms) {
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
5
+ // A blocking sleep, for callers that cannot go async (see withLockSync below) — Atomics.wait on
6
+ // a throwaway SharedArrayBuffer is the standard way to synchronously pause a Node.js thread
7
+ // without a busy-loop burning CPU.
8
+ function sleepSync(ms) {
9
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
10
+ }
11
+ export async function withLock(lockPath, fn, opts = {}) {
12
+ const retries = opts.retries ?? 100;
13
+ const delayMs = opts.delayMs ?? 20;
14
+ let fd = null;
15
+ for (let attempt = 0; attempt <= retries; attempt++) {
16
+ try {
17
+ fd = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
18
+ break;
19
+ }
20
+ catch (err) {
21
+ if (err.code !== 'EEXIST')
22
+ throw err;
23
+ if (attempt === retries)
24
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
25
+ await sleep(delayMs);
26
+ }
27
+ }
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ if (fd !== null)
33
+ closeSync(fd);
34
+ unlinkSync(lockPath);
35
+ }
36
+ }
37
+ // Synchronous sibling of withLock, for callers on a synchronous public API (e.g. runLoad) that
38
+ // cannot be made async without rippling out to every caller. Same atomic exclusive-create
39
+ // technique; a shorter default retry budget since callers using this are on a hot, latency-
40
+ // sensitive path and the critical section (spawnDaemonProcess is a non-blocking spawn().unref())
41
+ // is expected to be sub-millisecond, not something worth blocking a CLI invocation over.
42
+ export function withLockSync(lockPath, fn, opts = {}) {
43
+ const retries = opts.retries ?? 25;
44
+ const delayMs = opts.delayMs ?? 4;
45
+ let fd = null;
46
+ for (let attempt = 0; attempt <= retries; attempt++) {
47
+ try {
48
+ fd = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
49
+ break;
50
+ }
51
+ catch (err) {
52
+ if (err.code !== 'EEXIST')
53
+ throw err;
54
+ if (attempt === retries)
55
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
56
+ sleepSync(delayMs);
57
+ }
58
+ }
59
+ try {
60
+ return fn();
61
+ }
62
+ finally {
63
+ if (fd !== null)
64
+ closeSync(fd);
65
+ unlinkSync(lockPath);
66
+ }
67
+ }
@@ -0,0 +1,19 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ export function readIndex(indexPath) {
3
+ if (!existsSync(indexPath))
4
+ return {};
5
+ const raw = readFileSync(indexPath, 'utf-8').trim();
6
+ if (raw.length === 0)
7
+ return {};
8
+ try {
9
+ return JSON.parse(raw);
10
+ }
11
+ catch (err) {
12
+ throw new Error(`Corrupt memory index at ${indexPath}: ${err.message}`);
13
+ }
14
+ }
15
+ export function upsertIndexEntry(indexPath, file, summary) {
16
+ const index = readIndex(indexPath);
17
+ index[file] = { lastUpdated: new Date().toISOString(), summary };
18
+ writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n');
19
+ }
@@ -0,0 +1,43 @@
1
+ import { join, resolve, relative, isAbsolute } from 'node:path';
2
+ export const WRITABLE_FILES = [
3
+ 'context/projectBrief.md',
4
+ 'context/objectives.md',
5
+ 'context/activeContext.md',
6
+ 'context/decisions.md',
7
+ 'context/progress.md',
8
+ 'context/learnings.md',
9
+ 'context/currentMentalModel.md',
10
+ 'technical/architecture.md',
11
+ 'technical/techContext.md',
12
+ 'technical/patterns.md',
13
+ 'technical/integrations.md',
14
+ 'technical/infrastructure.md',
15
+ 'business/productContext.md',
16
+ 'business/roadmap.md',
17
+ 'business/stakeholders.md',
18
+ 'business/marketContext.md',
19
+ 'research/findings.md',
20
+ 'research/references.md',
21
+ 'research/hypotheses.md'
22
+ ];
23
+ export class UnsafePathError extends Error {
24
+ constructor(relFile) {
25
+ super(`"${relFile}" is not a recognized Memory Intel file and cannot be written.`);
26
+ }
27
+ }
28
+ export function assertSafePath(root, relFile) {
29
+ if (isAbsolute(relFile) || !WRITABLE_FILES.includes(relFile)) {
30
+ throw new UnsafePathError(relFile);
31
+ }
32
+ // Containment check via path.relative(), not a hardcoded '/' string prefix - resolve() on
33
+ // Windows returns backslash-separated paths, so `resolved.startsWith(resolvedRoot + '/')`
34
+ // fails unconditionally on every call there, rejecting every legitimate write. Caught live by
35
+ // CI's Windows matrix job the first time it ran - the whole write path was broken on Windows.
36
+ const resolved = resolve(root, relFile);
37
+ const resolvedRoot = resolve(root);
38
+ const rel = relative(resolvedRoot, resolved);
39
+ if (rel.startsWith('..') || isAbsolute(rel)) {
40
+ throw new UnsafePathError(relFile);
41
+ }
42
+ return join(root, relFile);
43
+ }