memoryintel 1.1.2 → 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.
@@ -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.2"
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.2"
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.2",
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 ~300 lines per file by default — load cost stays roughly flat as a project grows. The
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
@@ -176,7 +176,10 @@ async function main() {
176
176
  const result = await runUpdate(root, planText);
177
177
  // root printed first, same reasoning as load/status: a wrong-directory update should
178
178
  // never be silent.
179
- process.stdout.write(`root: ${root}\nApplied: ${result.applied.join(', ') || '(none)'}\nSkipped: ${result.skipped.join(', ') || '(none)'}\n`);
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}`);
180
183
  process.exitCode = 0;
181
184
  return;
182
185
  }
@@ -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 [--domain technical|business|research]\` and treat its output as project context.
32
- Its manifest reports each loaded file's \`lines\`, \`ceiling\`, and \`status\` (\`over\`/\`under\`) — see
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
@@ -129,8 +136,10 @@ recoverable from git history — it just won't be loaded by default anymore. Bec
129
136
  your summary can't answer, you compressed too much — keep more.
130
137
 
131
138
  The ceiling itself is configurable in \`memory-config.json\` under a \`compression\` key
132
- (\`defaultCeilingLines\`, and optional \`domainOverrides\` keyed by domain, e.g. \`"technical": 500\`)
133
- — the built-in default is 300 lines if unset.
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.
134
143
 
135
144
  ## Dashboard
136
145
  If the user asks to turn off the dashboard/web UI, run \`memoryintel dashboard disable\`. This is a
@@ -3,7 +3,7 @@ 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 { getCeilingLines, countLines } from '../core/compressionConfig.js';
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';
@@ -27,6 +27,46 @@ function assertKnownDomain(domain) {
27
27
  throw new UnknownDomainError(domain);
28
28
  }
29
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
+ }
30
70
  export function runLoad(cwd, domain) {
31
71
  // Validate before touching disk so a bad --domain always produces a clear, named error
32
72
  // rather than spreading `undefined` out of DOMAIN_FILES.
@@ -42,7 +82,19 @@ export function runLoad(cwd, domain) {
42
82
  catch {
43
83
  // Dashboard visibility is best-effort — never let it break `load`.
44
84
  }
45
- const files = [...ALWAYS_LOAD, ...(domain ? DOMAIN_FILES[domain] : [])];
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);
46
98
  const sections = [];
47
99
  const manifestRows = [];
48
100
  // `status` already surfaces lastUpdated from this same index - `load` is the one command
@@ -56,27 +108,58 @@ export function runLoad(cwd, domain) {
56
108
  continue;
57
109
  const content = readFileSync(absPath, 'utf-8');
58
110
  const lines = countLines(content);
59
- const ceiling = getCeilingLines(root, relFile);
60
- const status = lines > ceiling ? 'over' : 'under';
111
+ const ceiling = getCeilingChars(root, relFile);
112
+ const status = content.length > ceiling ? 'over' : 'under';
61
113
  sections.push(`--- FILE: ${relFile} ---\n${content}`);
62
114
  manifestRows.push({
63
115
  file: relFile,
64
116
  headings: extractHeadings(content).join('|'),
65
117
  lines: String(lines),
118
+ chars: String(content.length),
66
119
  ceiling: String(ceiling),
67
120
  status,
68
121
  lastUpdated: index[relFile]?.lastUpdated ?? 'never'
69
122
  });
70
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
+ : '';
71
150
  try {
72
151
  const totalChars = sections.reduce((sum, s) => sum + s.length, 0);
73
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
+ : '';
74
156
  appendEvent(join(root, 'memory-events.jsonl'), {
75
157
  timestamp: new Date().toISOString(),
76
158
  type: 'session-load',
77
- summary: `Loaded ${manifestRows.length} file(s)${domain ? ` (domain: ${domain})` : ''}`,
159
+ summary: `Loaded ${manifestRows.length} file(s)${domainLabel}`,
78
160
  affectedFiles: manifestRows.map((r) => r.file),
79
- domain: domain ?? null,
161
+ domain: effectiveDomain ?? null,
162
+ domainSource,
80
163
  totalChars,
81
164
  totalLines
82
165
  });
@@ -90,5 +173,5 @@ export function runLoad(cwd, domain) {
90
173
  // cwd with no built-in visibility into which root it actually found, so confidently-wrong
91
174
  // content came back with nothing to flag it. This is always the first thing printed,
92
175
  // whether or not --domain is given.
93
- return `root: ${root}\n${manifest}\n${sections.join('\n')}`;
176
+ return `root: ${root}\n${manifest}${domainIndex}\n${sections.join('\n')}`;
94
177
  }
@@ -6,11 +6,12 @@ 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 { withLock } from '../core/lock.js';
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 {
@@ -21,7 +22,11 @@ export async function runUpdate(root, planText) {
21
22
  // Dashboard visibility is best-effort — never let it break `update`.
22
23
  }
23
24
  const rows = decodePlanRows(planText);
24
- return withLock(join(root, '.lock'), () => {
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
- export const DEFAULT_CEILING_LINES = 300;
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 getCeilingLines then resolves to the
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 getCeilingLines(root, relFile) {
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.defaultCeilingLines === 'number')
35
- return config.defaultCeilingLines;
36
- return DEFAULT_CEILING_LINES;
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
- export async function withLock(lockPath, fn, opts = {}) {
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
- fd = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
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
- if (fd !== null)
33
- closeSync(fd);
34
- unlinkSync(lockPath);
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 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
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
- return normalizeHeading(existingBlock).includes(normalizeHeading(newContent));
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
  }
@@ -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
- // has never touched that file (see src/commands/init.ts). Checking it here was dead code: it
11
- // could never be true for any project using the documented setup, which is why a real project
12
- // (distilled-docs) never showed claude-code despite Claude driving every session. The
13
- // Stop-hook's `.session-marker.json` (written by check-stop / resolveCheckStopMarker, see
14
- // src/adapters/claudeCode.ts) only ever exists once the plugin's Stop hook has actually fired
15
- // for this project - real evidence of Claude Code automation running, not just installed.
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 (settingsWired || existsSync(sessionMarkerPath)) {
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 { getCeilingLines, countLines } from '../../core/compressionConfig.js';
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 lines = countLines(content);
27
- const ceiling = getCeilingLines(memoryRoot, file);
28
- const sizeClass = lines > ceiling ? 'stale' : 'muted';
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoryintel",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Persistent, cross-session project memory for AI coding agents.",
5
5
  "type": "module",
6
6
  "bin": {