memoryintel 1.1.1 → 1.1.3
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/cli.js +39 -9
- package/dist/commands/init.js +22 -5
- package/dist/commands/load.js +103 -8
- package/dist/commands/status.js +5 -1
- package/dist/commands/update.js +27 -5
- package/dist/core/compressionConfig.js +11 -6
- package/dist/core/lock.js +27 -11
- package/dist/core/sectionWriter.js +25 -1
- package/dist/core/toon.js +64 -1
- package/dist/daemon/registry.js +8 -11
- package/dist/daemon/views/projectPage.js +4 -5
- package/package.json +1 -1
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Persistent, cross-session project memory for AI coding agents.",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.3"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "memoryintel",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent project memory for AI coding agents — initialize once, then agents automatically load and update project understanding across sessions.",
|
|
16
|
-
"version": "1.1.
|
|
16
|
+
"version": "1.1.3"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoryintel",
|
|
3
3
|
"description": "Persistent project memory for AI coding agents — initialize once, then agents automatically load and update project understanding across sessions.",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Adeesh Sharma",
|
|
7
7
|
"url": "https://github.com/adeeshsharma"
|
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ systems: 85–93%), despite solving a different problem (durable project state,
|
|
|
176
176
|
history).
|
|
177
177
|
|
|
178
178
|
**Why the gap widens over time, not just per-call:** `.memoryintel/` content is self-compressing,
|
|
179
|
-
capped at ~
|
|
179
|
+
capped at ~12,000 chars per file by default — load cost stays roughly flat as a project grows. The
|
|
180
180
|
no-memory alternative doesn't; it scales with total codebase size. A project one day old and one
|
|
181
181
|
a year old cost about the same to bootstrap with Memory Intel. Without it, the older project costs
|
|
182
182
|
more, every single session.
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { findMemoryIntelRoot } from './core/discovery.js';
|
|
6
6
|
import { runUpdate } from './commands/update.js';
|
|
@@ -13,6 +13,26 @@ import { runCheckStop } from './adapters/claudeCode.js';
|
|
|
13
13
|
import { runDashboardEnable, runDashboardDisable } from './commands/dashboardToggle.js';
|
|
14
14
|
import { runDaemonStart } from './commands/daemonStart.js';
|
|
15
15
|
import { runDoctor } from './commands/doctor.js';
|
|
16
|
+
// Every command below that resolves .memoryintel/ by walking up from a starting directory
|
|
17
|
+
// previously always used process.cwd() with no override - meaning a long, multi-project
|
|
18
|
+
// session had to religiously `cd` before every single call, with silently-wrong output the
|
|
19
|
+
// only feedback for getting it wrong (mitigated separately by printing the resolved root, but
|
|
20
|
+
// that only helps you NOTICE the mistake, not avoid making it). --root <path> lets a caller
|
|
21
|
+
// that already knows its target project say so directly; MEMORYINTEL_ROOT is the same thing
|
|
22
|
+
// as an env var, for a session-start hook or similar context where passing an extra CLI flag
|
|
23
|
+
// isn't convenient. --root wins when both are given. Either way this is resolved exactly like
|
|
24
|
+
// process.cwd() always was - a starting point findMemoryIntelRoot() walks up from, not
|
|
25
|
+
// required to already BE the exact .memoryintel directory itself.
|
|
26
|
+
function resolveStartDir(argv) {
|
|
27
|
+
const rootFlagIndex = argv.indexOf('--root');
|
|
28
|
+
if (rootFlagIndex !== -1 && argv[rootFlagIndex + 1]) {
|
|
29
|
+
return resolve(process.cwd(), argv[rootFlagIndex + 1]);
|
|
30
|
+
}
|
|
31
|
+
if (process.env.MEMORYINTEL_ROOT) {
|
|
32
|
+
return resolve(process.cwd(), process.env.MEMORYINTEL_ROOT);
|
|
33
|
+
}
|
|
34
|
+
return process.cwd();
|
|
35
|
+
}
|
|
16
36
|
export const USAGE = `Usage: memoryintel <command> [options]
|
|
17
37
|
|
|
18
38
|
Commands:
|
|
@@ -52,24 +72,24 @@ export function dispatch(argv) {
|
|
|
52
72
|
case 'load': {
|
|
53
73
|
const domainFlagIndex = argv.indexOf('--domain');
|
|
54
74
|
const domain = domainFlagIndex !== -1 ? argv[domainFlagIndex + 1] : undefined;
|
|
55
|
-
const output = runLoad(
|
|
75
|
+
const output = runLoad(resolveStartDir(argv), domain);
|
|
56
76
|
return { exitCode: 0, stdout: output, stderr: '' };
|
|
57
77
|
}
|
|
58
78
|
case 'status': {
|
|
59
|
-
const root = findMemoryIntelRoot(
|
|
79
|
+
const root = findMemoryIntelRoot(resolveStartDir(argv));
|
|
60
80
|
if (!root)
|
|
61
81
|
return { exitCode: 1, stdout: '', stderr: 'No .memoryintel/ found.\n' };
|
|
62
82
|
return { exitCode: 0, stdout: runStatus(root), stderr: '' };
|
|
63
83
|
}
|
|
64
84
|
case 'check-stop': {
|
|
65
|
-
const root = findMemoryIntelRoot(
|
|
85
|
+
const root = findMemoryIntelRoot(resolveStartDir(argv));
|
|
66
86
|
if (!root)
|
|
67
87
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
68
88
|
const result = runCheckStop(root);
|
|
69
89
|
return { exitCode: 0, stdout: JSON.stringify(result) + '\n', stderr: '' };
|
|
70
90
|
}
|
|
71
91
|
case 'doctor': {
|
|
72
|
-
const root = findMemoryIntelRoot(
|
|
92
|
+
const root = findMemoryIntelRoot(resolveStartDir(argv));
|
|
73
93
|
if (!root)
|
|
74
94
|
return { exitCode: 1, stdout: '', stderr: 'No .memoryintel/ found.\n' };
|
|
75
95
|
const force = argv.includes('--force');
|
|
@@ -130,13 +150,18 @@ async function main() {
|
|
|
130
150
|
return;
|
|
131
151
|
}
|
|
132
152
|
if (command === 'update') {
|
|
133
|
-
const root = findMemoryIntelRoot(
|
|
153
|
+
const root = findMemoryIntelRoot(resolveStartDir(argv));
|
|
134
154
|
if (!root) {
|
|
135
155
|
process.stderr.write('No .memoryintel/ found.\n');
|
|
136
156
|
process.exitCode = 1;
|
|
137
157
|
return;
|
|
138
158
|
}
|
|
139
|
-
|
|
159
|
+
// The plan-file path is the first REMAINING positional argument after stripping the
|
|
160
|
+
// command name itself and a --root <path> pair, if given - otherwise "--root"'s own
|
|
161
|
+
// value would get misread as the plan-file path.
|
|
162
|
+
const rootFlagIndex = argv.indexOf('--root');
|
|
163
|
+
const positional = argv.filter((_, i) => i !== 0 && i !== rootFlagIndex && i !== rootFlagIndex + 1);
|
|
164
|
+
const source = positional[0] ?? '-';
|
|
140
165
|
const planText = source === '-' ? readFileSync(0, 'utf-8') : readFileSync(source, 'utf-8');
|
|
141
166
|
// Caught live: an agent ran bare `memoryintel update` (no plan-file argument, no piped
|
|
142
167
|
// stdin) as a one-shot Bash tool call. `source` defaulted to '-' (read stdin), stdin was
|
|
@@ -144,12 +169,17 @@ async function main() {
|
|
|
144
169
|
// generic "Malformed TOON table header" error - true, but useless for figuring out what
|
|
145
170
|
// actually went wrong. This is the one case worth naming explicitly before it gets there.
|
|
146
171
|
if (planText.trim().length === 0) {
|
|
147
|
-
process.stderr.write('memoryintel: No update-plan given. Pass a
|
|
172
|
+
process.stderr.write('memoryintel: No update-plan given. Pass a plan file (TOON or JSON, `memoryintel update <path>`) or pipe it via stdin. See .memoryintel/instructions.md for the update-plan format.\n');
|
|
148
173
|
process.exitCode = 1;
|
|
149
174
|
return;
|
|
150
175
|
}
|
|
151
176
|
const result = await runUpdate(root, planText);
|
|
152
|
-
|
|
177
|
+
// root printed first, same reasoning as load/status: a wrong-directory update should
|
|
178
|
+
// never be silent.
|
|
179
|
+
const overCeilingLine = result.overCeiling.length > 0
|
|
180
|
+
? `Over ceiling (consider compressing): ${result.overCeiling.join(', ')}\n`
|
|
181
|
+
: '';
|
|
182
|
+
process.stdout.write(`root: ${root}\nApplied: ${result.applied.join(', ') || '(none)'}\nSkipped: ${result.skipped.join(', ') || '(none)'}\n${overCeilingLine}`);
|
|
153
183
|
process.exitCode = 0;
|
|
154
184
|
return;
|
|
155
185
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -28,9 +28,16 @@ time. Both commands are safe to run more than once - \`import\`'s already-import
|
|
|
28
28
|
skipped, not duplicated, and \`scan\` never writes anything at all.
|
|
29
29
|
|
|
30
30
|
## Session start
|
|
31
|
-
Run \`memoryintel load
|
|
32
|
-
Its manifest reports each loaded file's \`lines\`, \`ceiling\`, and \`status\`
|
|
33
|
-
"Compaction" below for what to do about a file marked \`over\`.
|
|
31
|
+
Run \`memoryintel load\` (no arguments — the hook does this automatically) and treat its output as
|
|
32
|
+
project context. Its manifest reports each loaded file's \`lines\`, \`ceiling\`, and \`status\`
|
|
33
|
+
(\`over\`/\`under\`) — see "Compaction" below for what to do about a file marked \`over\`.
|
|
34
|
+
|
|
35
|
+
\`load\` with no \`--domain\` automatically carries forward whichever domain the most recent
|
|
36
|
+
\`update\` actually touched, so continuing yesterday's technical work loads \`technical/*\` again
|
|
37
|
+
without you having to ask for it. The one case this doesn't cover is deliberately switching to a
|
|
38
|
+
domain nothing was just written to — check the "Other memory available" list at the bottom of
|
|
39
|
+
\`load\`'s output and, if the task is about a topic listed there, run
|
|
40
|
+
\`memoryintel load --domain <domain>\` yourself before continuing.
|
|
34
41
|
|
|
35
42
|
## Session end
|
|
36
43
|
If your work changed project understanding (new architecture, feature, decision, integration, or
|
|
@@ -62,6 +69,14 @@ outright rather than degrading gracefully, so match it exactly rather than impro
|
|
|
62
69
|
- \`context/currentMentalModel.md\` is the one exception to \`action\`: its row's \`content\` replaces
|
|
63
70
|
the file's entire content verbatim, regardless of what \`action\`/\`section\` say.
|
|
64
71
|
|
|
72
|
+
**\`update\` also accepts a plain JSON array of the same rows instead of TOON**, auto-detected by
|
|
73
|
+
whether the file/stdin content starts with \`[\` or \`{\` after trimming - if you'd rather
|
|
74
|
+
\`JSON.stringify\` a plan than hand-write TOON's quoting rule above, this is the safer default:
|
|
75
|
+
\`[{"file": "path/to/file.md", "action": "append", "section": "Section Heading", "content": "New
|
|
76
|
+
paragraph to add.", "reason": "Why this changed"}]\`. Same fields, same required order doesn't
|
|
77
|
+
matter (JSON is keyed, not positional), same \`context/currentMentalModel.md\` exception. Neither
|
|
78
|
+
format is preferred - use whichever you're less likely to get wrong.
|
|
79
|
+
|
|
65
80
|
Also include a row for \`context/currentMentalModel.md\` whenever the update is more than a small,
|
|
66
81
|
localized fact — anything that shifts what the project *is* or where it currently stands (not
|
|
67
82
|
every single decision/progress entry needs one). Unlike every other file, it is a **whole-file
|
|
@@ -121,8 +136,10 @@ recoverable from git history — it just won't be loaded by default anymore. Bec
|
|
|
121
136
|
your summary can't answer, you compressed too much — keep more.
|
|
122
137
|
|
|
123
138
|
The ceiling itself is configurable in \`memory-config.json\` under a \`compression\` key
|
|
124
|
-
(\`
|
|
125
|
-
— the built-in default is
|
|
139
|
+
(\`defaultCeilingChars\`, and optional \`domainOverrides\` keyed by domain, e.g. \`"technical": 20000\`)
|
|
140
|
+
— the built-in default is 12000 chars if unset. \`update\` also flags a file that crosses the
|
|
141
|
+
ceiling right after the write that pushed it over, in the same turn — don't wait for the next
|
|
142
|
+
\`load\` to notice.
|
|
126
143
|
|
|
127
144
|
## Dashboard
|
|
128
145
|
If the user asks to turn off the dashboard/web UI, run \`memoryintel dashboard disable\`. This is a
|
package/dist/commands/load.js
CHANGED
|
@@ -3,10 +3,11 @@ import { join, dirname } from 'node:path';
|
|
|
3
3
|
import { findMemoryIntelRoot } from '../core/discovery.js';
|
|
4
4
|
import { extractHeadings } from '../core/headingMatch.js';
|
|
5
5
|
import { encodeToonTable } from '../core/toon.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getCeilingChars, countLines } from '../core/compressionConfig.js';
|
|
7
7
|
import { ensureDaemonRunning } from '../daemon/lifecycle.js';
|
|
8
8
|
import { upsertRegistryEntry } from '../daemon/registry.js';
|
|
9
9
|
import { appendEvent } from '../core/eventLog.js';
|
|
10
|
+
import { readIndex } from '../core/memoryIndex.js';
|
|
10
11
|
const ALWAYS_LOAD = ['context/currentMentalModel.md', 'context/activeContext.md'];
|
|
11
12
|
const DOMAIN_FILES = {
|
|
12
13
|
technical: ['technical/architecture.md', 'technical/techContext.md', 'technical/patterns.md'],
|
|
@@ -26,6 +27,46 @@ function assertKnownDomain(domain) {
|
|
|
26
27
|
throw new UnknownDomainError(domain);
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
function domainOf(relFile) {
|
|
31
|
+
const segment = relFile.split('/')[0];
|
|
32
|
+
return Object.prototype.hasOwnProperty.call(DOMAIN_FILES, segment) ? segment : null;
|
|
33
|
+
}
|
|
34
|
+
// A heading-only index of unloaded domain files is a nudge, not a guarantee - nothing forces an
|
|
35
|
+
// agent to notice it and pass --domain. Carrying forward whichever domain the *previous* session
|
|
36
|
+
// actually wrote to removes the agent's judgment from the common case entirely: if last session's
|
|
37
|
+
// work touched business/roadmap.md, this session's bare `load` (no --domain given, which is all
|
|
38
|
+
// the SessionStart hook ever passes) already includes that domain, because it's the domain most
|
|
39
|
+
// likely still relevant. Only switching to a domain untouched in the most recent write still
|
|
40
|
+
// depends on the agent reading the "Other memory available" index below and acting on it.
|
|
41
|
+
function inferRecentDomain(eventsPath) {
|
|
42
|
+
if (!existsSync(eventsPath))
|
|
43
|
+
return null;
|
|
44
|
+
let lines;
|
|
45
|
+
try {
|
|
46
|
+
lines = readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
52
|
+
let event;
|
|
53
|
+
try {
|
|
54
|
+
event = JSON.parse(lines[i]);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!event || typeof event !== 'object')
|
|
60
|
+
continue;
|
|
61
|
+
const { type, affectedFiles } = event;
|
|
62
|
+
if (type !== 'memory-update' || !Array.isArray(affectedFiles))
|
|
63
|
+
continue;
|
|
64
|
+
const domain = typeof affectedFiles[0] === 'string' ? domainOf(affectedFiles[0]) : null;
|
|
65
|
+
if (domain)
|
|
66
|
+
return domain;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
29
70
|
export function runLoad(cwd, domain) {
|
|
30
71
|
// Validate before touching disk so a bad --domain always produces a clear, named error
|
|
31
72
|
// rather than spreading `undefined` out of DOMAIN_FILES.
|
|
@@ -41,35 +82,84 @@ export function runLoad(cwd, domain) {
|
|
|
41
82
|
catch {
|
|
42
83
|
// Dashboard visibility is best-effort — never let it break `load`.
|
|
43
84
|
}
|
|
44
|
-
|
|
85
|
+
// An explicit --domain always wins. Only when the caller (in practice: the SessionStart hook,
|
|
86
|
+
// which never passes --domain) leaves it unset do we fall back to the last-touched domain.
|
|
87
|
+
let effectiveDomain = domain;
|
|
88
|
+
let domainSource = domain ? 'explicit' : null;
|
|
89
|
+
if (effectiveDomain === undefined) {
|
|
90
|
+
const inferred = inferRecentDomain(join(root, 'memory-events.jsonl'));
|
|
91
|
+
if (inferred) {
|
|
92
|
+
effectiveDomain = inferred;
|
|
93
|
+
domainSource = 'auto';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const files = [...ALWAYS_LOAD, ...(effectiveDomain ? DOMAIN_FILES[effectiveDomain] : [])];
|
|
97
|
+
const loadedSet = new Set(files);
|
|
45
98
|
const sections = [];
|
|
46
99
|
const manifestRows = [];
|
|
100
|
+
// `status` already surfaces lastUpdated from this same index - `load` is the one command
|
|
101
|
+
// instructions.md tells every session to run FIRST, though, and previously gave zero signal
|
|
102
|
+
// for "just updated" vs. "nobody has touched this in months" without a separate `status`
|
|
103
|
+
// call nothing prompts an agent to make.
|
|
104
|
+
const index = readIndex(join(root, 'memory-index.json'));
|
|
47
105
|
for (const relFile of files) {
|
|
48
106
|
const absPath = join(root, relFile);
|
|
49
107
|
if (!existsSync(absPath))
|
|
50
108
|
continue;
|
|
51
109
|
const content = readFileSync(absPath, 'utf-8');
|
|
52
110
|
const lines = countLines(content);
|
|
53
|
-
const ceiling =
|
|
54
|
-
const status =
|
|
111
|
+
const ceiling = getCeilingChars(root, relFile);
|
|
112
|
+
const status = content.length > ceiling ? 'over' : 'under';
|
|
55
113
|
sections.push(`--- FILE: ${relFile} ---\n${content}`);
|
|
56
114
|
manifestRows.push({
|
|
57
115
|
file: relFile,
|
|
58
116
|
headings: extractHeadings(content).join('|'),
|
|
59
117
|
lines: String(lines),
|
|
118
|
+
chars: String(content.length),
|
|
60
119
|
ceiling: String(ceiling),
|
|
61
|
-
status
|
|
120
|
+
status,
|
|
121
|
+
lastUpdated: index[relFile]?.lastUpdated ?? 'never'
|
|
62
122
|
});
|
|
63
123
|
}
|
|
124
|
+
// Domain files exist only to be pulled in via `load --domain <d>`, which nothing prompts an
|
|
125
|
+
// agent to do proactively - in practice this leaves them written by `update()` but never read
|
|
126
|
+
// back. A heading-only index (no content, so this costs tens of tokens rather than the
|
|
127
|
+
// hundreds/thousands a full domain would) at least makes their existence and topic visible on
|
|
128
|
+
// every load, so an agent can decide to pull one in instead of the content silently going
|
|
129
|
+
// stale and unread.
|
|
130
|
+
const domainIndexRows = [];
|
|
131
|
+
for (const [domainName, domainFiles] of Object.entries(DOMAIN_FILES)) {
|
|
132
|
+
for (const relFile of domainFiles) {
|
|
133
|
+
if (loadedSet.has(relFile))
|
|
134
|
+
continue;
|
|
135
|
+
const absPath = join(root, relFile);
|
|
136
|
+
if (!existsSync(absPath))
|
|
137
|
+
continue;
|
|
138
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
139
|
+
domainIndexRows.push({
|
|
140
|
+
domain: domainName,
|
|
141
|
+
file: relFile,
|
|
142
|
+
headings: extractHeadings(content).join('|'),
|
|
143
|
+
lines: String(countLines(content))
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const domainIndex = domainIndexRows.length > 0
|
|
148
|
+
? `\nOther memory available (not loaded — run \`memoryintel load --domain <domain>\` to include):\n${encodeToonTable(domainIndexRows)}`
|
|
149
|
+
: '';
|
|
64
150
|
try {
|
|
65
151
|
const totalChars = sections.reduce((sum, s) => sum + s.length, 0);
|
|
66
152
|
const totalLines = manifestRows.reduce((sum, r) => sum + Number(r.lines), 0);
|
|
153
|
+
const domainLabel = effectiveDomain
|
|
154
|
+
? ` (domain: ${effectiveDomain}${domainSource === 'auto' ? ', auto-carried from last update' : ''})`
|
|
155
|
+
: '';
|
|
67
156
|
appendEvent(join(root, 'memory-events.jsonl'), {
|
|
68
157
|
timestamp: new Date().toISOString(),
|
|
69
158
|
type: 'session-load',
|
|
70
|
-
summary: `Loaded ${manifestRows.length} file(s)${
|
|
159
|
+
summary: `Loaded ${manifestRows.length} file(s)${domainLabel}`,
|
|
71
160
|
affectedFiles: manifestRows.map((r) => r.file),
|
|
72
|
-
domain:
|
|
161
|
+
domain: effectiveDomain ?? null,
|
|
162
|
+
domainSource,
|
|
73
163
|
totalChars,
|
|
74
164
|
totalLines
|
|
75
165
|
});
|
|
@@ -78,5 +168,10 @@ export function runLoad(cwd, domain) {
|
|
|
78
168
|
// KPI telemetry is best-effort - never let logging a load break the load itself.
|
|
79
169
|
}
|
|
80
170
|
const manifest = encodeToonTable(manifestRows);
|
|
81
|
-
|
|
171
|
+
// A leading, plainly-labeled root line - silently loading the wrong project's (or wrong
|
|
172
|
+
// worktree/branch's) memory has happened in practice: findMemoryIntelRoot() walks up from
|
|
173
|
+
// cwd with no built-in visibility into which root it actually found, so confidently-wrong
|
|
174
|
+
// content came back with nothing to flag it. This is always the first thing printed,
|
|
175
|
+
// whether or not --domain is given.
|
|
176
|
+
return `root: ${root}\n${manifest}${domainIndex}\n${sections.join('\n')}`;
|
|
82
177
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -3,8 +3,12 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { readIndex } from '../core/memoryIndex.js';
|
|
4
4
|
export function runStatus(root) {
|
|
5
5
|
const lines = [];
|
|
6
|
+
// Always first: silently reading the wrong project's (or wrong worktree/branch's) memory
|
|
7
|
+
// has happened in practice - findMemoryIntelRoot() walks up from cwd with no built-in
|
|
8
|
+
// visibility into which root it actually resolved.
|
|
9
|
+
lines.push('=== Root ===', root);
|
|
6
10
|
const mentalModelPath = join(root, 'context', 'currentMentalModel.md');
|
|
7
|
-
lines.push('=== Current Mental Model ===');
|
|
11
|
+
lines.push('', '=== Current Mental Model ===');
|
|
8
12
|
lines.push(existsSync(mentalModelPath) ? readFileSync(mentalModelPath, 'utf-8').trim() : '(none)');
|
|
9
13
|
lines.push('', '=== Memory Index ===');
|
|
10
14
|
const index = readIndex(join(root, 'memory-index.json'));
|
package/dist/commands/update.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join, posix, dirname } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { decodePlanRows } from '../core/toon.js';
|
|
4
4
|
import { applySectionUpdate, isNearDuplicate, getSectionContent } from '../core/sectionWriter.js';
|
|
5
5
|
import { assertSafePath } from '../core/pathSafety.js';
|
|
6
6
|
import { upsertIndexEntry } from '../core/memoryIndex.js';
|
|
7
7
|
import { appendEvent } from '../core/eventLog.js';
|
|
8
8
|
import { atomicWriteFile } from '../core/atomicWrite.js';
|
|
9
|
-
import {
|
|
9
|
+
import { withLocks } from '../core/lock.js';
|
|
10
10
|
import { ensureDaemonRunning } from '../daemon/lifecycle.js';
|
|
11
11
|
import { upsertRegistryEntry } from '../daemon/registry.js';
|
|
12
12
|
import { resolveCheckStopMarker } from '../adapters/claudeCode.js';
|
|
13
13
|
import { isPathClean } from '../core/gitPorcelain.js';
|
|
14
|
+
import { getCeilingChars } from '../core/compressionConfig.js';
|
|
14
15
|
const MENTAL_MODEL_FILE = 'context/currentMentalModel.md';
|
|
15
16
|
export async function runUpdate(root, planText) {
|
|
16
17
|
try {
|
|
@@ -20,8 +21,12 @@ export async function runUpdate(root, planText) {
|
|
|
20
21
|
catch {
|
|
21
22
|
// Dashboard visibility is best-effort — never let it break `update`.
|
|
22
23
|
}
|
|
23
|
-
const rows =
|
|
24
|
-
|
|
24
|
+
const rows = decodePlanRows(planText);
|
|
25
|
+
// Locking only the files this plan actually touches (rather than one project-wide lock) lets
|
|
26
|
+
// two update() calls on disjoint files - e.g. two subagents each owning a different domain -
|
|
27
|
+
// run concurrently instead of serializing on each other.
|
|
28
|
+
const lockPaths = [...new Set(rows.map((row) => `${assertSafePath(root, row.file)}.lock`))];
|
|
29
|
+
return withLocks(lockPaths, () => {
|
|
25
30
|
// Phase 1: validate every entry against current disk state, compute the writes, write nothing yet.
|
|
26
31
|
const writes = [];
|
|
27
32
|
// Tracks each path's content as computed so far *this call*, so a second row targeting a
|
|
@@ -78,6 +83,7 @@ export async function runUpdate(root, planText) {
|
|
|
78
83
|
// Phase 2: apply. Every entry above already validated, so this cannot fail on content grounds.
|
|
79
84
|
const applied = [];
|
|
80
85
|
const skipped = [];
|
|
86
|
+
const overCeiling = [];
|
|
81
87
|
for (const w of writes) {
|
|
82
88
|
if (w.skipped) {
|
|
83
89
|
// A dropped write is still a fact about this session — log it so `status` can show
|
|
@@ -101,8 +107,24 @@ export async function runUpdate(root, planText) {
|
|
|
101
107
|
affectedFiles: [w.relFile]
|
|
102
108
|
});
|
|
103
109
|
applied.push(w.relFile);
|
|
110
|
+
// The compression ceiling used to be purely advisory: `load()` would flag a file as
|
|
111
|
+
// "over" in its manifest, but nothing surfaced that until the *next* session bothered to
|
|
112
|
+
// read the manifest. Flagging it here, in the same turn that pushed a file over, means the
|
|
113
|
+
// agent that just wrote the content is the one told to compress it - the one with the most
|
|
114
|
+
// context to do so well - rather than leaving it for whoever loads next.
|
|
115
|
+
const ceiling = getCeilingChars(root, w.relFile);
|
|
116
|
+
if (w.newContent.length > ceiling) {
|
|
117
|
+
const reason = `${w.relFile} is ${w.newContent.length} chars, over its ${ceiling}-char ceiling — consider a compress row before the next session.`;
|
|
118
|
+
appendEvent(join(root, 'memory-events.jsonl'), {
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
type: 'over-ceiling',
|
|
121
|
+
summary: reason,
|
|
122
|
+
affectedFiles: [w.relFile]
|
|
123
|
+
});
|
|
124
|
+
overCeiling.push(w.relFile);
|
|
125
|
+
}
|
|
104
126
|
}
|
|
105
127
|
resolveCheckStopMarker(root);
|
|
106
|
-
return { applied, skipped };
|
|
128
|
+
return { applied, skipped, overCeiling };
|
|
107
129
|
});
|
|
108
130
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
3
|
+
// ~40 chars/line is a reasonable prose average, so this stays roughly equivalent to the old
|
|
4
|
+
// 300-line default while actually measuring the thing that determines context cost: characters,
|
|
5
|
+
// not lines. A file of long, dense lines and one of short, sparse lines could both read "300
|
|
6
|
+
// lines" while costing very different amounts of context - line count was a proxy that stopped
|
|
7
|
+
// tracking the number it was supposed to.
|
|
8
|
+
export const DEFAULT_CEILING_CHARS = 12000;
|
|
4
9
|
// Reads memory-config.json's optional `compression` block. Missing file, missing key, or
|
|
5
|
-
// corrupt JSON all fall back to an empty config (which
|
|
10
|
+
// corrupt JSON all fall back to an empty config (which getCeilingChars then resolves to the
|
|
6
11
|
// built-in default) — this is a read-time convenience for load()/the dashboard, never a place
|
|
7
12
|
// that should throw and interrupt them.
|
|
8
13
|
function readCompressionConfig(root) {
|
|
@@ -25,13 +30,13 @@ export function countLines(content) {
|
|
|
25
30
|
}
|
|
26
31
|
// relFile's first path segment (e.g. "technical" from "technical/architecture.md", or "context"
|
|
27
32
|
// from "context/activeContext.md") is the domain domainOverrides keys against.
|
|
28
|
-
export function
|
|
33
|
+
export function getCeilingChars(root, relFile) {
|
|
29
34
|
const config = readCompressionConfig(root);
|
|
30
35
|
const domain = relFile.split('/')[0];
|
|
31
36
|
const override = config.domainOverrides?.[domain];
|
|
32
37
|
if (typeof override === 'number')
|
|
33
38
|
return override;
|
|
34
|
-
if (typeof config.
|
|
35
|
-
return config.
|
|
36
|
-
return
|
|
39
|
+
if (typeof config.defaultCeilingChars === 'number')
|
|
40
|
+
return config.defaultCeilingChars;
|
|
41
|
+
return DEFAULT_CEILING_CHARS;
|
|
37
42
|
}
|
package/dist/core/lock.js
CHANGED
|
@@ -8,14 +8,10 @@ function sleep(ms) {
|
|
|
8
8
|
function sleepSync(ms) {
|
|
9
9
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
const retries = opts.retries ?? 100;
|
|
13
|
-
const delayMs = opts.delayMs ?? 20;
|
|
14
|
-
let fd = null;
|
|
11
|
+
async function acquireLock(lockPath, retries, delayMs) {
|
|
15
12
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
16
13
|
try {
|
|
17
|
-
|
|
18
|
-
break;
|
|
14
|
+
return openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
19
15
|
}
|
|
20
16
|
catch (err) {
|
|
21
17
|
if (err.code !== 'EEXIST')
|
|
@@ -25,17 +21,37 @@ export async function withLock(lockPath, fn, opts = {}) {
|
|
|
25
21
|
await sleep(delayMs);
|
|
26
22
|
}
|
|
27
23
|
}
|
|
24
|
+
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
25
|
+
}
|
|
26
|
+
// Acquires every lock in `lockPaths` (deduped, sorted into one global acquisition order so any
|
|
27
|
+
// two callers that both need locks A and B can never deadlock by acquiring them in opposite
|
|
28
|
+
// order) before running fn, releasing them all afterward even if fn throws. This is what lets a
|
|
29
|
+
// caller lock only the specific files a given operation touches - e.g. update() locking just the
|
|
30
|
+
// files named in its plan - instead of one project-wide lock that would serialize every update()
|
|
31
|
+
// call against every other, even when they touch entirely disjoint files.
|
|
32
|
+
export async function withLocks(lockPaths, fn, opts = {}) {
|
|
33
|
+
const retries = opts.retries ?? 100;
|
|
34
|
+
const delayMs = opts.delayMs ?? 20;
|
|
35
|
+
const sorted = [...new Set(lockPaths)].sort();
|
|
36
|
+
const fds = [];
|
|
28
37
|
try {
|
|
38
|
+
for (const lockPath of sorted) {
|
|
39
|
+
fds.push(await acquireLock(lockPath, retries, delayMs));
|
|
40
|
+
}
|
|
29
41
|
return await fn();
|
|
30
42
|
}
|
|
31
43
|
finally {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
// Release in reverse acquisition order; each lock is independent so any partial-acquisition
|
|
45
|
+
// failure above only needs to unwind what was actually opened, which the fds/sorted-prefix
|
|
46
|
+
// pairing here already reflects.
|
|
47
|
+
for (let i = fds.length - 1; i >= 0; i--) {
|
|
48
|
+
closeSync(fds[i]);
|
|
49
|
+
unlinkSync(sorted[i]);
|
|
50
|
+
}
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
|
-
// Synchronous sibling of
|
|
38
|
-
// cannot be made async without rippling out to every caller. Same atomic exclusive-create
|
|
53
|
+
// Synchronous, single-lock sibling of withLocks, for callers on a synchronous public API (e.g.
|
|
54
|
+
// runLoad) that cannot be made async without rippling out to every caller. Same atomic exclusive-create
|
|
39
55
|
// technique; a shorter default retry budget since callers using this are on a hot, latency-
|
|
40
56
|
// sensitive path and the critical section (spawnDaemonProcess is a non-blocking spawn().unref())
|
|
41
57
|
// is expected to be sub-millisecond, not something worth blocking a CLI invocation over.
|
|
@@ -86,6 +86,30 @@ export function applySectionUpdate(markdown, section, action, content) {
|
|
|
86
86
|
const result = [...before, ...newContentLines, ...after].join('\n');
|
|
87
87
|
return result.endsWith('\n') ? result : result + '\n';
|
|
88
88
|
}
|
|
89
|
+
const DUPLICATE_STOPWORDS = new Set([
|
|
90
|
+
'the', 'and', 'for', 'are', 'was', 'were', 'with', 'this', 'that', 'from', 'have', 'has'
|
|
91
|
+
]);
|
|
92
|
+
// Words under 3 chars and common connectors are dropped before comparing - otherwise two
|
|
93
|
+
// sentences sharing only "the", "and", "is" would register as near-duplicates of each other.
|
|
94
|
+
function meaningfulTokens(s) {
|
|
95
|
+
return normalizeHeading(s)
|
|
96
|
+
.split(/[^a-z0-9]+/)
|
|
97
|
+
.filter((t) => t.length >= 3 && !DUPLICATE_STOPWORDS.has(t));
|
|
98
|
+
}
|
|
99
|
+
const TOKEN_OVERLAP_THRESHOLD = 0.85;
|
|
89
100
|
export function isNearDuplicate(existingBlock, newContent) {
|
|
90
|
-
|
|
101
|
+
// Fast path: catches whitespace-only diffs and literal restatements.
|
|
102
|
+
if (normalizeHeading(existingBlock).includes(normalizeHeading(newContent)))
|
|
103
|
+
return true;
|
|
104
|
+
// Reordered/lightly-reworded restatements of the same fact aren't a literal substring of the
|
|
105
|
+
// existing block, so the check above misses them - which is exactly how the same fact
|
|
106
|
+
// re-enters memory in slightly different words each session, quietly working against
|
|
107
|
+
// self-compression. Below a handful of meaningful words, overlap ratios get noisy on trivial
|
|
108
|
+
// content, so short additions fall back to the literal check above only.
|
|
109
|
+
const newTokens = meaningfulTokens(newContent);
|
|
110
|
+
if (newTokens.length < 3)
|
|
111
|
+
return false;
|
|
112
|
+
const existingTokens = new Set(meaningfulTokens(existingBlock));
|
|
113
|
+
const overlap = newTokens.filter((t) => existingTokens.has(t)).length;
|
|
114
|
+
return overlap / newTokens.length >= TOKEN_OVERLAP_THRESHOLD;
|
|
91
115
|
}
|
package/dist/core/toon.js
CHANGED
|
@@ -66,7 +66,10 @@ function parseCsvRows(text) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
if (inQuotes) {
|
|
69
|
-
|
|
69
|
+
// rows.length is exactly how many COMPLETE rows were parsed before the failure - the
|
|
70
|
+
// failing row is the next one, at that same 0-indexed position (matching how a
|
|
71
|
+
// field-count-mismatch error below reports rowIndex).
|
|
72
|
+
throw new Error(`Malformed TOON table: unterminated quoted field in row ${rows.length}.`);
|
|
70
73
|
}
|
|
71
74
|
if (rowStarted)
|
|
72
75
|
endRow();
|
|
@@ -116,3 +119,63 @@ export function decodeToonTable(text) {
|
|
|
116
119
|
return row;
|
|
117
120
|
});
|
|
118
121
|
}
|
|
122
|
+
const REQUIRED_PLAN_ROW_FIELDS = ['file', 'action', 'section', 'content', 'reason'];
|
|
123
|
+
const OPTIONAL_PLAN_ROW_FIELDS = ['kind'];
|
|
124
|
+
function decodeJsonPlan(text) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
131
|
+
throw new Error(`Malformed JSON plan: ${message}`);
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(parsed)) {
|
|
134
|
+
throw new Error('Malformed JSON plan: expected an array of rows.');
|
|
135
|
+
}
|
|
136
|
+
return parsed.map((row, rowIndex) => {
|
|
137
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
138
|
+
throw new Error(`Malformed JSON plan: row ${rowIndex} must be an object.`);
|
|
139
|
+
}
|
|
140
|
+
const record = row;
|
|
141
|
+
for (const field of REQUIRED_PLAN_ROW_FIELDS) {
|
|
142
|
+
if (!(field in record)) {
|
|
143
|
+
throw new Error(`Malformed JSON plan: row ${rowIndex} is missing required field "${field}".`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const result = {};
|
|
147
|
+
for (const field of [...REQUIRED_PLAN_ROW_FIELDS, ...OPTIONAL_PLAN_ROW_FIELDS]) {
|
|
148
|
+
if (!(field in record))
|
|
149
|
+
continue;
|
|
150
|
+
const value = record[field];
|
|
151
|
+
if (typeof value !== 'string') {
|
|
152
|
+
throw new Error(`Malformed JSON plan: row ${rowIndex}'s "${field}" must be a string.`);
|
|
153
|
+
}
|
|
154
|
+
result[field] = value;
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Accepts either format update() itself needs to parse: TOON text (the
|
|
161
|
+
* default, more compact for a human/agent to scan), or a JSON array of the
|
|
162
|
+
* same row shape - auto-detected by a leading '[' after trimming, since a
|
|
163
|
+
* real TOON header always starts with the literal 'items['; this can never
|
|
164
|
+
* misfire on a genuinely malformed TOON table. JSON exists specifically so
|
|
165
|
+
* an agent that would rather JSON.stringify a plan than hand-author TOON's
|
|
166
|
+
* own quoting rules (double an internal '"' as '""', not backslash-escape
|
|
167
|
+
* it - a real, repeated mistake in practice, not hypothetical) never has to
|
|
168
|
+
* risk getting that escaping wrong at all. decodeToonTable itself is
|
|
169
|
+
* unchanged and still exported directly for anyone who wants TOON
|
|
170
|
+
* specifically.
|
|
171
|
+
*/
|
|
172
|
+
export function decodePlanRows(text) {
|
|
173
|
+
const trimmed = text.trimStart();
|
|
174
|
+
// A real TOON header never starts with '[' or '{' either (always the literal
|
|
175
|
+
// 'items[') - catching '{' too means a plan mistakenly wrapped as a single
|
|
176
|
+
// object instead of an array gets the clear "expected an array" error below,
|
|
177
|
+
// not an unrelated, confusing TOON header error.
|
|
178
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{'))
|
|
179
|
+
return decodeJsonPlan(trimmed);
|
|
180
|
+
return decodeToonTable(text);
|
|
181
|
+
}
|
package/dist/daemon/registry.js
CHANGED
|
@@ -6,18 +6,15 @@ const MARKER = 'memoryintel:managed:start';
|
|
|
6
6
|
export function detectToolsWired(projectRoot) {
|
|
7
7
|
const tools = [];
|
|
8
8
|
// Claude Code automation comes entirely from this package's bundled plugin
|
|
9
|
-
// (hooks/hooks.json), never from writing to the project's own .claude/settings.json - init
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// Still also honor a manually-wired settings.json, for anyone who set one up by hand.
|
|
17
|
-
const claudeSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
18
|
-
const settingsWired = existsSync(claudeSettingsPath) && readFileSync(claudeSettingsPath, 'utf-8').includes('memoryintel load');
|
|
9
|
+
// (hooks/hooks.json), never from writing to the project's own .claude/settings.json - init has
|
|
10
|
+
// never touched that file. The Stop-hook's `.session-marker.json` (written by check-stop /
|
|
11
|
+
// resolveCheckStopMarker, see src/adapters/claudeCode.ts) only ever exists once the plugin's
|
|
12
|
+
// Stop hook has actually fired for this project - real evidence of Claude Code automation
|
|
13
|
+
// running, not just installed. A prior version of this check also looked for a hand-wired
|
|
14
|
+
// .claude/settings.json; dropped after confirming on a real project (distilled-docs) that
|
|
15
|
+
// nothing ever writes that file, so the check could never fire in practice.
|
|
19
16
|
const sessionMarkerPath = join(projectRoot, '.memoryintel', '.session-marker.json');
|
|
20
|
-
if (
|
|
17
|
+
if (existsSync(sessionMarkerPath)) {
|
|
21
18
|
tools.push('claude-code');
|
|
22
19
|
}
|
|
23
20
|
if (existsSync(join(projectRoot, '.cursor', 'rules', 'memoryintel.mdc'))) {
|
|
@@ -3,7 +3,7 @@ import { join, basename } from 'node:path';
|
|
|
3
3
|
import { WRITABLE_FILES } from '../../core/pathSafety.js';
|
|
4
4
|
import { computeFileHealth } from '../health.js';
|
|
5
5
|
import { detectToolsWired } from '../registry.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getCeilingChars } from '../../core/compressionConfig.js';
|
|
7
7
|
import { escapeHtml, pageShell, freshnessTier, formatAge } from './layout.js';
|
|
8
8
|
function renderFileBrowser(memoryRoot) {
|
|
9
9
|
const groups = {};
|
|
@@ -23,10 +23,9 @@ function renderFileBrowser(memoryRoot) {
|
|
|
23
23
|
const lastUpdated = healthByFile[file]?.lastUpdated;
|
|
24
24
|
const tier = freshnessTier(staleness ?? null);
|
|
25
25
|
const stalenessLabel = lastUpdated ? formatAge(Date.now() - new Date(lastUpdated).getTime()) : 'never updated';
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const sizeLabel = `${lines}/${ceiling} lines`;
|
|
26
|
+
const ceiling = getCeilingChars(memoryRoot, file);
|
|
27
|
+
const sizeClass = content.length > ceiling ? 'stale' : 'muted';
|
|
28
|
+
const sizeLabel = `${content.length}/${ceiling} chars`;
|
|
30
29
|
return `<details><summary>${escapeHtml(file)} <span class="muted stale-label ${tier}">(${stalenessLabel})</span> <span class="${sizeClass}">${escapeHtml(sizeLabel)}</span></summary><pre>${escapeHtml(content || '(empty)')}</pre></details>`;
|
|
31
30
|
}).join('\n');
|
|
32
31
|
return `<h3>${escapeHtml(domain)}</h3>\n${items}`;
|