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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/adapters/claudeCode.js +96 -0
- package/dist/adapters/genericPointer.js +39 -0
- package/dist/cli.js +157 -0
- package/dist/commands/daemonStart.js +8 -0
- package/dist/commands/dashboardToggle.js +17 -0
- package/dist/commands/init.js +111 -0
- package/dist/commands/load.js +82 -0
- package/dist/commands/status.js +24 -0
- package/dist/commands/update.js +108 -0
- package/dist/core/atomicWrite.js +6 -0
- package/dist/core/compressionConfig.js +37 -0
- package/dist/core/discovery.js +14 -0
- package/dist/core/eventLog.js +4 -0
- package/dist/core/gitPorcelain.js +45 -0
- package/dist/core/headingMatch.js +44 -0
- package/dist/core/lock.js +67 -0
- package/dist/core/memoryIndex.js +19 -0
- package/dist/core/pathSafety.js +43 -0
- package/dist/core/sectionWriter.js +91 -0
- package/dist/core/toon.js +118 -0
- package/dist/daemon/daemonHandle.js +52 -0
- package/dist/daemon/globalPaths.js +15 -0
- package/dist/daemon/health.js +14 -0
- package/dist/daemon/lifecycle.js +54 -0
- package/dist/daemon/registry.js +60 -0
- package/dist/daemon/server.js +92 -0
- package/dist/daemon/settings.js +13 -0
- package/dist/daemon/views/layout.js +233 -0
- package/dist/daemon/views/projectPage.js +111 -0
- package/dist/daemon/views/registryPage.js +54 -0
- package/dist/skill.js +46 -0
- package/dist/templates/starterFiles.js +22 -0
- package/hooks/hooks.json +11 -0
- package/package.json +52 -0
- 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,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,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
|
+
}
|