memoryintel 1.1.3 → 1.2.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.
@@ -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.3"
9
+ "version": "1.2.0"
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.3"
16
+ "version": "1.2.0"
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.3",
4
+ "version": "1.2.0",
5
5
  "author": {
6
6
  "name": "Adeesh Sharma",
7
7
  "url": "https://github.com/adeeshsharma"
package/README.md CHANGED
@@ -13,6 +13,13 @@ This repository is itself running Memory Intel on itself — see `.memoryintel/`
13
13
  current state, decisions, and open todo items. Any agent with the skill below active will read it
14
14
  automatically.
15
15
 
16
+ ![Memory Intel's dashboard: a registry of projects and one project's mental model](assets/dashboard-demo.gif)
17
+
18
+ *The local dashboard (`memoryintel dashboard enable`) — a read-only view of every initialized
19
+ project on the machine. Demo data shown above; captured against the real running server, not
20
+ mocked up. [Watch the full tour (mp4)](assets/dashboard-demo.mp4) for automation status, session
21
+ activity, and the event timeline too.*
22
+
16
23
  ## Quick Start
17
24
 
18
25
  ```bash
@@ -88,7 +95,9 @@ depend on the agent noticing anything.
88
95
  ### If you don't want to touch Claude Code's plugin system at all
89
96
 
90
97
  The CLI works standalone, with no plugin/skill/hook involved — useful for scripting, for other
91
- tools, or just to try it out:
98
+ tools, or just to try it out. This is exactly what a `SessionStart`/`Stop` hook runs for you
99
+ automatically when a plugin is active — shown here run by hand so you can see what actually
100
+ happens under the hood:
92
101
 
93
102
  ```bash
94
103
  memoryintel init # once per project — scaffolds .memoryintel/, installs pointer files
@@ -97,6 +106,13 @@ memoryintel load # print resolved context to stdout
97
106
  memoryintel update plan.toon # apply an update-plan
98
107
  ```
99
108
 
109
+ ![Terminal walkthrough: memoryintel init, then load printing an empty scaffold](assets/cli-walkthrough.gif)
110
+
111
+ *`init` → `load` → draft a real update-plan → `update` → `load` again, now auto-carrying the
112
+ domain the update just touched. Every command above actually ran; nothing is a typed-out
113
+ transcript. [Watch the full walkthrough (mp4)](assets/cli-walkthrough.mp4) for the update-plan and
114
+ the auto-carry-domain payoff.*
115
+
100
116
  `memoryintel init` never touches a project's own `.claude/settings.json` — Claude Code automation
101
117
  comes entirely from the plugin's own `hooks/hooks.json` in this repo, active once the plugin
102
118
  itself is active. From then on, agents load and update project memory on their own, per that
@@ -106,6 +122,10 @@ If a shared local dashboard is running (a read-only view of every initialized pr
106
122
  machine), turn it off any time with `memoryintel dashboard disable` — or back on with
107
123
  `memoryintel dashboard enable`.
108
124
 
125
+ `memoryintel sync` re-runs automatic stack/integration/deployment fact detection by hand (see
126
+ "How it works" below) — useful for debugging, or to see what it found without waiting for the
127
+ next `load`/`check-stop`.
128
+
109
129
  ## Prerequisites
110
130
 
111
131
  Node.js ≥18 — actually verified as the real floor (CI runs the full suite on Node 18), not an
@@ -113,12 +133,76 @@ assumed default.
113
133
 
114
134
  ## How it works
115
135
 
136
+ ![Architecture diagram: a session starts and calls load(), which pulls context from .memoryintel/ (auto-carrying whichever domain the last update touched) into the agent; the agent works; update() writes back to .memoryintel/ as an atomic, per-file-locked write; the next session repeats the cycle](assets/architecture-flow.svg)
137
+
138
+ `.memoryintel/` is a structured, git-committed set of markdown/JSON files — the single source of
139
+ truth both directions in the diagram above read from and write to. At session start, `load()`
140
+ prints the always-loaded files plus whichever technical/business/research domain the most recent
141
+ `update()` actually touched (an explicit `--domain` still overrides). The agent works, then drafts
142
+ an update-plan and calls `update()`, which validates it, writes atomically under a per-file lock,
143
+ and logs the change — never a changelog, always a maintained understanding of the project as it
144
+ currently is.
145
+
146
+ A second, independent mechanism runs alongside the agent-driven one above: every `load` and
147
+ `check-stop` call also auto-detects mechanically-verifiable stack, integration, and deployment
148
+ facts (a dependency in `package.json`, a `vercel.json`, a `Dockerfile`) and writes them straight
149
+ into `technical/techContext.md` / `integrations.md` / `infrastructure.md` — no agent judgment
150
+ involved, no user action required. This is deliberately narrower than the agent-driven mechanism:
151
+ it only ever asserts what it can prove from a file that's actually there. `memoryintel sync` runs
152
+ the same detection by hand, for debugging.
153
+
116
154
  Full design docs live in `docs/superpowers/specs/`; a diagram-heavy architecture reference lives
117
- in `docs/architecture/memory-intel-architecture.html`. In short: `.memoryintel/` is a structured,
118
- git-committed set of markdown/JSON files an agent reads at session start and selectively updates
119
- when something meaningful changes — never a changelog, always a maintained understanding of the
120
- project as it currently is. See `.memoryintel/context/decisions.md` in this very repository for
121
- the specific design decisions behind that, with rationale.
155
+ in `docs/architecture/memory-intel-architecture.html`. See `.memoryintel/context/decisions.md` in
156
+ this very repository for the specific design decisions behind the mechanism above, with rationale.
157
+
158
+ ### What `memoryintel init` actually creates
159
+
160
+ Exactly this, and nothing else — every file below is real output from a fresh `memoryintel init`,
161
+ not a hand-typed example:
162
+
163
+ ```text
164
+ .memoryintel/
165
+ ├── instructions.md # what an agent reads every session — the mechanism, in full
166
+ ├── memory-config.json # compression ceiling overrides, generated-file hashes (for `doctor`)
167
+ ├── memory-index.json # lastUpdated + one-line summary per file, keyed by path
168
+ ├── memory-events.jsonl # append-only log: every load/update/compression, ever
169
+
170
+ ├── context/ # always loaded in full — no --domain needed
171
+ │ ├── currentMentalModel.md # whole-file replace only; the one narrative summary, not a log
172
+ │ ├── activeContext.md # what session-to-session work is focused on right now
173
+ │ ├── projectBrief.md # what the project is, for someone who's never seen it
174
+ │ ├── objectives.md # goals the project is actually working toward
175
+ │ ├── decisions.md # append-only decision log, with rationale
176
+ │ ├── progress.md # what's done, what's in flight
177
+ │ └── learnings.md # things worth not re-learning the hard way
178
+
179
+ ├── technical/ # a --domain: architecture, stack, patterns
180
+ │ ├── architecture.md
181
+ │ ├── techContext.md
182
+ │ ├── patterns.md
183
+ │ ├── integrations.md
184
+ │ └── infrastructure.md
185
+
186
+ ├── business/ # a --domain: product, roadmap, stakeholders
187
+ │ ├── productContext.md
188
+ │ ├── roadmap.md
189
+ │ ├── stakeholders.md
190
+ │ └── marketContext.md
191
+
192
+ └── research/ # a --domain: findings, open questions
193
+ ├── findings.md
194
+ ├── references.md
195
+ └── hypotheses.md
196
+
197
+ AGENTS.md # pointer file (or .cursor/rules/memoryintel.mdc, GEMINI.md) —
198
+ # tells tools with no native hook where instructions.md lives
199
+ ```
200
+
201
+ `context/` loads on every session automatically; `technical/`, `business/`, and `research/` are
202
+ domains — `load()` pulls in whichever one the most recent `update()` touched, or you can ask for
203
+ one explicitly (`memoryintel load --domain technical`). Every file starts as an empty, headed
204
+ scaffold; there's no separate "add a new memory type" step; you just write to any of the 19 files
205
+ above via an update-plan, same as any other.
122
206
 
123
207
  ## Existing projects (not greenfield)
124
208
 
@@ -201,7 +285,7 @@ cd memoryintel
201
285
  npm install
202
286
  npm run build # compiles dist/, regenerates skills/memoryintel/SKILL.md from src/skill.ts
203
287
  npm link # makes `memoryintel` resolve to this exact checkout instead of the published one
204
- npm test # 207 tests, vitest
288
+ npm test # 300 tests, vitest
205
289
  npm run build:skill:check # fails if skills/memoryintel/SKILL.md has drifted from src/skill.ts
206
290
  ```
207
291
 
@@ -1,6 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { runGitStatusPorcelain, porcelainPath, runGitRevParseHead } from '../core/gitPorcelain.js';
4
+ import { syncDetectedFacts } from '../core/factSync.js';
5
+ // Same manifest filenames detectStack() (core/repoScan.ts) already recognizes - kept as a local
6
+ // copy rather than importing repoScan's own internal list, since this is a small, stable set and
7
+ // a shared-constant module for five string literals would be more machinery than the duplication
8
+ // it avoids.
9
+ const MANIFEST_FILES = new Set(['package.json', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml']);
4
10
  function readMarker(markerPath) {
5
11
  if (!existsSync(markerPath))
6
12
  return { lastFlaggedDiffSignature: null };
@@ -56,15 +62,18 @@ function isWorkingTreeDirty(projectRoot) {
56
62
  return path !== '.memoryintel' && !path.startsWith('.memoryintel/');
57
63
  });
58
64
  }
65
+ // Claude Code's Stop hook JSON schema only recognizes decision: "block" - there is no "allow"
66
+ // value, so the non-blocking cases must return {} (decision omitted), not { decision: 'allow' },
67
+ // or Claude Code rejects the hook output outright ("Hook JSON output validation failed").
59
68
  export function runCheckStop(memoryRoot) {
60
69
  const projectRoot = dirname(memoryRoot);
61
70
  const markerPath = join(memoryRoot, '.session-marker.json');
62
71
  const marker = readMarker(markerPath);
63
72
  const signature = computeDiffSignature(projectRoot);
64
73
  if (signature === null)
65
- return { decision: 'allow' };
74
+ return {};
66
75
  if (signature === marker.lastFlaggedDiffSignature) {
67
- return { decision: 'allow' };
76
+ return {};
68
77
  }
69
78
  // A brand-new marker (nothing has ever been flagged or resolved in this project) with a
70
79
  // currently-clean working tree has nothing actionable to report - baseline silently so a
@@ -73,13 +82,23 @@ export function runCheckStop(memoryRoot) {
73
82
  // project's very first Stop event.
74
83
  if (marker.lastFlaggedDiffSignature === null && !isWorkingTreeDirty(projectRoot)) {
75
84
  writeMarker(markerPath, { lastFlaggedDiffSignature: signature });
76
- return { decision: 'allow' };
85
+ return {};
77
86
  }
78
87
  writeMarker(markerPath, { lastFlaggedDiffSignature: signature });
79
- return {
80
- decision: 'block',
81
- reason: "Working tree has changes memory hasn't accounted for. Classify them, write a TOON update-plan, and run `memoryintel update <plan-file>` (see .memoryintel/instructions.md) before finishing - running `memoryintel update` bare, with no plan file, fails. Or finish again to proceed without updating this time."
82
- };
88
+ let reason = "Working tree has changes memory hasn't accounted for. Classify them, write a TOON update-plan, and run `memoryintel update <plan-file>` (see .memoryintel/instructions.md) before finishing - running `memoryintel update` bare, with no plan file, fails. Or finish again to proceed without updating this time.";
89
+ // Mechanism 2 (see docs/superpowers/specs/2026-09-06-automatic-fact-detection-design.md): when
90
+ // the diff touches a manifest file, name specifically what auto-detection just found, so the
91
+ // agent's own follow-up judgment gets a concrete lead instead of only "something changed, go
92
+ // figure out what." This never changes the block/allow decision itself - only the reason text.
93
+ const statusLines = runGitStatusPorcelain(projectRoot);
94
+ const touchesManifest = statusLines !== null && statusLines.some((l) => MANIFEST_FILES.has(porcelainPath(l)));
95
+ if (touchesManifest) {
96
+ const syncResult = syncDetectedFacts(memoryRoot);
97
+ if (syncResult.written.length > 0) {
98
+ reason += ` Also: a manifest file changed — auto-detected facts were just written to ${syncResult.written.join(', ')}. Worth a note elsewhere (e.g. why it was added) if that's not just incidental.`;
99
+ }
100
+ }
101
+ return { decision: 'block', reason };
83
102
  }
84
103
  // Called after a successful `update`. Does NOT simply clear the marker to null — `update` only
85
104
  // writes to .memoryintel/, so the user's actual source diff that triggered the nudge (e.g. an
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ 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
+ import { syncDetectedFacts } from './core/factSync.js';
16
17
  // Every command below that resolves .memoryintel/ by walking up from a starting directory
17
18
  // previously always used process.cwd() with no override - meaning a long, multi-project
18
19
  // session had to religiously `cd` before every single call, with silently-wrong output the
@@ -45,6 +46,9 @@ Commands:
45
46
  update <plan.toon|-> Apply an update-plan (file path, or - for stdin)
46
47
  status Print a human-readable summary of current memory state
47
48
  check-stop Stop-hook check: emit a JSON allow/block decision
49
+ sync Re-scan for stack/integration/deployment facts and write any new
50
+ findings - runs automatically via load/check-stop; this is the
51
+ manual/debugging entry point
48
52
  dashboard <enable|disable> Turn the shared local dashboard on or off
49
53
  doctor [--force] Refresh memoryintel's own generated files (instructions.md, pointer
50
54
  blocks) to the current template wherever it's provably safe;
@@ -88,6 +92,16 @@ export function dispatch(argv) {
88
92
  const result = runCheckStop(root);
89
93
  return { exitCode: 0, stdout: JSON.stringify(result) + '\n', stderr: '' };
90
94
  }
95
+ case 'sync': {
96
+ const root = findMemoryIntelRoot(resolveStartDir(argv));
97
+ if (!root)
98
+ return { exitCode: 1, stdout: '', stderr: 'No .memoryintel/ found.\n' };
99
+ const result = syncDetectedFacts(root);
100
+ const stdout = result.written.length > 0
101
+ ? `root: ${root}\nUpdated: ${result.written.join(', ')}\n`
102
+ : `root: ${root}\nNo changes - detected facts already up to date.\n`;
103
+ return { exitCode: 0, stdout, stderr: '' };
104
+ }
91
105
  case 'doctor': {
92
106
  const root = findMemoryIntelRoot(resolveStartDir(argv));
93
107
  if (!root)
@@ -115,10 +115,12 @@ committed — not because updating was hard, but because nothing in the session
115
115
  before the worktree's job was considered done.
116
116
 
117
117
  ## Compaction
118
- A file marked \`status: over\` in \`load\`'s manifest has grown past its configured line ceiling.
119
- This is a signal, not a command compact it only when it's a sensible moment to (the same
120
- judgment you already apply to whether to update at all), by adding a row to your update-plan with
121
- one extra field, \`kind: compress\`, and \`action: replace\` against the section that's grown large.
118
+ A file marked \`status: over\` in \`load\`'s manifest has grown past its configured char ceiling
119
+ \`update\` also flags this itself, in the same call that pushes a file over, rather than waiting for
120
+ the next \`load\`. This is a signal, not a command compact it only when it's a sensible moment to
121
+ (the same judgment you already apply to whether to update at all), by adding a row to your
122
+ update-plan with one extra field, \`kind: compress\`, and \`action: replace\` against the section
123
+ that's grown large.
122
124
  \`update\` will only apply that row if the target file is currently git-clean — if it isn't, the row
123
125
  is rejected and the file is left untouched; commit the current state first, then retry. Aim to
124
126
  compact to comfortably under the ceiling, not exactly at it.
@@ -8,6 +8,7 @@ import { ensureDaemonRunning } from '../daemon/lifecycle.js';
8
8
  import { upsertRegistryEntry } from '../daemon/registry.js';
9
9
  import { appendEvent } from '../core/eventLog.js';
10
10
  import { readIndex } from '../core/memoryIndex.js';
11
+ import { syncDetectedFacts } from '../core/factSync.js';
11
12
  const ALWAYS_LOAD = ['context/currentMentalModel.md', 'context/activeContext.md'];
12
13
  const DOMAIN_FILES = {
13
14
  technical: ['technical/architecture.md', 'technical/techContext.md', 'technical/patterns.md'],
@@ -75,6 +76,13 @@ export function runLoad(cwd, domain) {
75
76
  const root = findMemoryIntelRoot(cwd);
76
77
  if (!root)
77
78
  return '';
79
+ try {
80
+ syncDetectedFacts(root);
81
+ }
82
+ catch {
83
+ // Best-effort, same policy as the daemon-registry call just below - auto-detection must
84
+ // never be the reason a session-start load fails.
85
+ }
78
86
  try {
79
87
  ensureDaemonRunning();
80
88
  upsertRegistryEntry(dirname(root));
@@ -21,7 +21,8 @@ export function runStatus(root) {
21
21
  const eventLines = readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
22
22
  for (const line of eventLines.slice(-5)) {
23
23
  const event = JSON.parse(line);
24
- lines.push(`[${event.timestamp}] ${event.type}: ${event.summary}`);
24
+ const sourceTag = event.source ? ` (${event.source})` : '';
25
+ lines.push(`[${event.timestamp}] ${event.type}${sourceTag}: ${event.summary}`);
25
26
  }
26
27
  }
27
28
  return lines.join('\n') + '\n';
@@ -93,7 +93,8 @@ export async function runUpdate(root, planText) {
93
93
  timestamp: new Date().toISOString(),
94
94
  type: w.eventType,
95
95
  summary: w.reason,
96
- affectedFiles: [w.relFile]
96
+ affectedFiles: [w.relFile],
97
+ source: 'agent'
97
98
  });
98
99
  skipped.push(w.relFile);
99
100
  continue;
@@ -104,7 +105,8 @@ export async function runUpdate(root, planText) {
104
105
  timestamp: new Date().toISOString(),
105
106
  type: w.eventType,
106
107
  summary: w.reason,
107
- affectedFiles: [w.relFile]
108
+ affectedFiles: [w.relFile],
109
+ source: 'agent'
108
110
  });
109
111
  applied.push(w.relFile);
110
112
  // The compression ceiling used to be purely advisory: `load()` would flag a file as
@@ -119,7 +121,8 @@ export async function runUpdate(root, planText) {
119
121
  timestamp: new Date().toISOString(),
120
122
  type: 'over-ceiling',
121
123
  summary: reason,
122
- affectedFiles: [w.relFile]
124
+ affectedFiles: [w.relFile],
125
+ source: 'agent'
123
126
  });
124
127
  overCeiling.push(w.relFile);
125
128
  }
@@ -0,0 +1,58 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { atomicWriteFile } from './atomicWrite.js';
3
+ import { getSectionContent, applySectionUpdate } from './sectionWriter.js';
4
+ export const DETECTED_START = '<!-- memoryintel:detected:start -->';
5
+ export const DETECTED_END = '<!-- memoryintel:detected:end -->';
6
+ function buildBlock(lines) {
7
+ return [DETECTED_START, ...lines, DETECTED_END].join('\n');
8
+ }
9
+ // Inserts or refreshes a memoryintel-owned "detected facts" block at the top of `heading`'s
10
+ // section content in the markdown file at absPath - modeled on the existing pointer-block
11
+ // mechanism in adapters/genericPointer.ts, generalized to work on an arbitrary heading inside an
12
+ // arbitrary file instead of one hardcoded block in one hardcoded set of files.
13
+ //
14
+ // Never creates the file (a directory `init` hasn't touched is left alone - 'missing-file') and
15
+ // never creates the heading either (`init`'s STARTER_FILES already ship every heading this is
16
+ // ever pointed at; a project on an old template without it is simply skipped - 'missing-heading'
17
+ // - rather than this feature mutating a file's structure it was never asked to manage).
18
+ //
19
+ // Content outside the markers - including agent-authored prose elsewhere in the very same
20
+ // section, above or below the block - is preserved byte-for-byte. This is what makes it safe to
21
+ // call this on every session start without ever risking real project understanding: the block is
22
+ // fully machine-owned, and everything else in the file categorically is not.
23
+ export function upsertDetectedBlock(absPath, heading, lines) {
24
+ if (!existsSync(absPath))
25
+ return 'missing-file';
26
+ const markdown = readFileSync(absPath, 'utf-8');
27
+ const sectionContent = getSectionContent(markdown, heading);
28
+ if (sectionContent === null)
29
+ return 'missing-heading';
30
+ const newBlock = buildBlock(lines);
31
+ const startIdx = sectionContent.indexOf(DETECTED_START);
32
+ const endIdx = sectionContent.indexOf(DETECTED_END);
33
+ let newSectionContent;
34
+ if (startIdx !== -1 && endIdx !== -1) {
35
+ const before = sectionContent.slice(0, startIdx);
36
+ const after = sectionContent.slice(endIdx + DETECTED_END.length);
37
+ newSectionContent = `${before}${newBlock}${after}`;
38
+ }
39
+ else {
40
+ // Fresh insertion (no existing block to slot into): `.trim()` here would swallow the blank
41
+ // line this project's own files (and getSectionContent's own single-blank-line stripping
42
+ // convention) always separate a heading, and every section, by - caught via dogfooding this
43
+ // feature on this very repo, where a fresh Stack block landed flush against `## Conventions`
44
+ // with zero blank line between them. The leading/trailing `\n` below combine with
45
+ // applySectionUpdate's own join('\n') separators (one on each side of this section's content)
46
+ // to reconstruct exactly one blank line after the heading and exactly one before whatever
47
+ // comes next (another heading, or end of file) - matching the surrounding blank-line rhythm
48
+ // instead of fighting it.
49
+ const rest = sectionContent.trim();
50
+ const body = rest.length > 0 ? `${newBlock}\n\n${rest}` : newBlock;
51
+ newSectionContent = `\n${body}\n`;
52
+ }
53
+ if (newSectionContent === sectionContent)
54
+ return 'unchanged';
55
+ const updated = applySectionUpdate(markdown, heading, 'replace', newSectionContent);
56
+ atomicWriteFile(absPath, updated);
57
+ return 'written';
58
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { detectStack } from './repoScan.js';
4
+ import { matchKnownFacts } from './knownFacts.js';
5
+ import { detectInfraSignals } from './infraSignals.js';
6
+ import { upsertDetectedBlock } from './detectedBlock.js';
7
+ import { upsertIndexEntry } from './memoryIndex.js';
8
+ import { appendEvent } from './eventLog.js';
9
+ import { withLockSync } from './lock.js';
10
+ const TARGETS = [
11
+ { relFile: 'technical/techContext.md', heading: 'Stack' },
12
+ { relFile: 'technical/integrations.md', heading: 'External Services' },
13
+ { relFile: 'technical/infrastructure.md', heading: 'Deployment' }
14
+ ];
15
+ // Automatically detects mechanically-verifiable stack/integration/deployment facts and writes
16
+ // them into their managed detected-block (detectedBlock.ts), with zero agent or user action
17
+ // required - called from both `load` (every session start) and `check-stop` (when the diff
18
+ // touches a manifest file), plus manually via `memoryintel sync`. A no-op (missing-file, no
19
+ // write, no event) on any target file `init` hasn't created - this feature is inert on an
20
+ // uninitialized or partially-initialized project, never scaffolding structure of its own.
21
+ export function syncDetectedFacts(memoryRoot) {
22
+ const projectRoot = dirname(memoryRoot);
23
+ const stack = detectStack(projectRoot);
24
+ const matched = matchKnownFacts(stack.dependencies);
25
+ const infraLines = detectInfraSignals(projectRoot);
26
+ const contentByTarget = {
27
+ 'technical/techContext.md': matched.techContext.length > 0 ? matched.techContext.map((f) => `- ${f}`) : ['_No stack manifest found._'],
28
+ 'technical/integrations.md': matched.integrations.length > 0
29
+ ? matched.integrations.map((f) => `- ${f}`)
30
+ : ['_No known integrations detected._'],
31
+ 'technical/infrastructure.md': infraLines.length > 0 ? infraLines.map((f) => `- ${f}`) : ['_No deployment signal found in repo._']
32
+ };
33
+ const written = [];
34
+ for (const { relFile, heading } of TARGETS) {
35
+ const absPath = join(memoryRoot, relFile);
36
+ // Checked before locking, not just left to upsertDetectedBlock's own 'missing-file' return:
37
+ // withLockSync's openSync(..., O_CREAT) needs the file's parent directory to already exist to
38
+ // create the `.lock` file in - an uninitialized project (no .memoryintel/technical/ at all)
39
+ // would otherwise throw ENOENT here instead of the intended silent no-op.
40
+ if (!existsSync(absPath))
41
+ continue;
42
+ const lockPath = `${absPath}.lock`;
43
+ // Same lock-file naming as update()'s per-file locking (`${absPath}.lock`) - a concurrent
44
+ // agent-driven update() and this auto-sync touching the same file correctly serialize
45
+ // against each other instead of racing.
46
+ const result = withLockSync(lockPath, () => upsertDetectedBlock(absPath, heading, contentByTarget[relFile]));
47
+ if (result === 'written') {
48
+ upsertIndexEntry(join(memoryRoot, 'memory-index.json'), relFile, 'Auto-detected stack/integration facts (memoryintel sync)');
49
+ appendEvent(join(memoryRoot, 'memory-events.jsonl'), {
50
+ timestamp: new Date().toISOString(),
51
+ type: 'fact-sync',
52
+ summary: `Auto-detected facts written to ${relFile}`,
53
+ affectedFiles: [relFile],
54
+ source: 'auto-detect'
55
+ });
56
+ written.push(relFile);
57
+ }
58
+ }
59
+ return { written };
60
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Presence-only, no version/config parsing. Vercel gets two independent signals
4
+ // (vercel.json and .vercel/) since either alone is common depending on whether the project was
5
+ // ever linked locally via `vercel link` vs. only ever deployed through the dashboard/CI.
6
+ const FILE_SIGNALS = [
7
+ { relPath: 'vercel.json', fact: 'Vercel' },
8
+ { relPath: '.vercel', fact: 'Vercel' },
9
+ { relPath: 'netlify.toml', fact: 'Netlify' },
10
+ { relPath: 'Dockerfile', fact: 'Docker' },
11
+ { relPath: 'docker-compose.yml', fact: 'Docker Compose' },
12
+ { relPath: 'fly.toml', fact: 'Fly.io' },
13
+ { relPath: 'render.yaml', fact: 'Render' },
14
+ { relPath: 'wrangler.toml', fact: 'Cloudflare Workers (via Wrangler)' },
15
+ { relPath: 'Procfile', fact: 'Heroku (or Heroku-compatible, via Procfile)' }
16
+ ];
17
+ // Known deploy-action name fragments to grep for inside .github/workflows/*.yml - deliberately
18
+ // substring matches (not parsing the `uses:` field structurally), since a real YAML parser is
19
+ // more machinery than a best-effort signal needs and every real deploy-action reference contains
20
+ // one of these fragments regardless of version pin or org fork.
21
+ const WORKFLOW_DEPLOY_ACTIONS = [
22
+ { fragment: 'vercel-action', fact: 'Vercel' },
23
+ { fragment: 'amondnet/vercel', fact: 'Vercel' },
24
+ { fragment: 'netlify/actions', fact: 'Netlify' },
25
+ { fragment: 'nwtgck/actions-netlify', fact: 'Netlify' },
26
+ { fragment: 'superfly/flyctl-actions', fact: 'Fly.io' },
27
+ { fragment: 'aws-actions/', fact: 'AWS (via GitHub Actions)' },
28
+ { fragment: 'google-github-actions/', fact: 'Google Cloud (via GitHub Actions)' },
29
+ { fragment: 'azure/webapps-deploy', fact: 'Azure' }
30
+ ];
31
+ function detectWorkflowSignals(targetDir) {
32
+ const workflowsDir = join(targetDir, '.github', 'workflows');
33
+ if (!existsSync(workflowsDir))
34
+ return [];
35
+ let files;
36
+ try {
37
+ files = readdirSync(workflowsDir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ const found = new Set();
43
+ for (const file of files) {
44
+ let content;
45
+ try {
46
+ content = readFileSync(join(workflowsDir, file), 'utf-8');
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ for (const { fragment, fact } of WORKFLOW_DEPLOY_ACTIONS) {
52
+ if (content.includes(fragment)) {
53
+ found.add(`${fact} — via \`${file}\` GitHub Actions workflow`);
54
+ }
55
+ }
56
+ }
57
+ return [...found];
58
+ }
59
+ // Best-effort only, by design: no dependency-based signal exists for "where is this deployed"
60
+ // (a project deployed via a hosting platform's dashboard, connected straight to its git remote,
61
+ // can leave zero trace in the repo itself - confirmed on a real project during this feature's own
62
+ // design). Returns an empty array when nothing is found; the caller decides how to render that
63
+ // as an explicit "no signal" statement rather than silence (see factSync.ts).
64
+ export function detectInfraSignals(targetDir) {
65
+ const found = new Set();
66
+ for (const { relPath, fact } of FILE_SIGNALS) {
67
+ if (existsSync(join(targetDir, relPath))) {
68
+ found.add(`${fact} — via \`${relPath}\``);
69
+ }
70
+ }
71
+ for (const line of detectWorkflowSignals(targetDir)) {
72
+ found.add(line);
73
+ }
74
+ return [...found];
75
+ }
@@ -0,0 +1,53 @@
1
+ // A curated, deliberately small starter set covering common frameworks, CMS, DB, payment, and
2
+ // auth libraries. Extending this is a one-line PR to this file - no plugin system needed at this
3
+ // scale (see spec's "Non-goals").
4
+ export const KNOWN_FACTS = [
5
+ { match: 'next', fact: 'Next.js (React framework)', target: 'techContext' },
6
+ { match: 'react', fact: 'React', target: 'techContext' },
7
+ { match: 'vue', fact: 'Vue', target: 'techContext' },
8
+ { match: 'svelte', fact: 'Svelte', target: 'techContext' },
9
+ { match: 'typescript', fact: 'TypeScript', target: 'techContext' },
10
+ { match: 'express', fact: 'Express', target: 'techContext' },
11
+ { match: 'fastify', fact: 'Fastify', target: 'techContext' },
12
+ { match: 'prisma', fact: 'Prisma (ORM)', target: 'techContext' },
13
+ { match: 'mongoose', fact: 'MongoDB (via Mongoose)', target: 'techContext' },
14
+ { match: 'tailwindcss', fact: 'Tailwind CSS', target: 'techContext' },
15
+ { match: ['@sanity/client', 'sanity'], fact: 'Sanity (headless CMS)', target: 'integrations' },
16
+ { match: 'contentful', fact: 'Contentful (headless CMS)', target: 'integrations' },
17
+ { match: 'stripe', fact: 'Stripe (payments)', target: 'integrations' },
18
+ { match: '@supabase/supabase-js', fact: 'Supabase', target: 'integrations' },
19
+ { match: 'firebase', fact: 'Firebase', target: 'integrations' },
20
+ { match: 'firebase-admin', fact: 'Firebase Admin SDK', target: 'integrations' },
21
+ { match: 'next-auth', fact: 'NextAuth.js (authentication)', target: 'integrations' },
22
+ { match: '@auth0/nextjs-auth0', fact: 'Auth0 (authentication)', target: 'integrations' },
23
+ { match: '@clerk/nextjs', fact: 'Clerk (authentication)', target: 'integrations' },
24
+ { match: '@sendgrid/mail', fact: 'SendGrid (email)', target: 'integrations' },
25
+ { match: 'resend', fact: 'Resend (email)', target: 'integrations' },
26
+ { match: 'twilio', fact: 'Twilio', target: 'integrations' },
27
+ { match: '@aws-sdk/client-s3', fact: 'AWS S3', target: 'integrations' },
28
+ { match: 'openai', fact: 'OpenAI API', target: 'integrations' },
29
+ { match: '@anthropic-ai/sdk', fact: 'Anthropic API', target: 'integrations' },
30
+ { match: '@sentry/node', fact: 'Sentry (error tracking)', target: 'integrations' },
31
+ { match: '@sentry/nextjs', fact: 'Sentry (error tracking)', target: 'integrations' },
32
+ { match: 'posthog-js', fact: 'PostHog (analytics)', target: 'integrations' },
33
+ { match: '@vercel/analytics', fact: 'Vercel Analytics', target: 'integrations' },
34
+ { match: 'graphql', fact: 'GraphQL', target: 'techContext' },
35
+ { match: 'redis', fact: 'Redis', target: 'techContext' },
36
+ { match: 'pg', fact: 'PostgreSQL (via pg)', target: 'techContext' }
37
+ ];
38
+ // Each returned fact line is self-documenting about its evidence (which exact dependency name
39
+ // triggered it) - a reader should never have to take the tool's word for a fact it can't trace
40
+ // back to something real in the repo. `${matched}` is deliberately the specific name that was
41
+ // actually present, not `known.match` itself, since that may be an array of aliases.
42
+ export function matchKnownFacts(dependencies) {
43
+ const deps = new Set(dependencies);
44
+ const result = { techContext: [], integrations: [] };
45
+ for (const known of KNOWN_FACTS) {
46
+ const candidates = Array.isArray(known.match) ? known.match : [known.match];
47
+ const matched = candidates.find((c) => deps.has(c));
48
+ if (matched) {
49
+ result[known.target].push(`${known.fact} — via \`${matched}\` in package.json`);
50
+ }
51
+ }
52
+ return result;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoryintel",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Persistent, cross-session project memory for AI coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,9 @@ Commands:
47
47
  update <plan.toon|-> Apply an update-plan (file path, or - for stdin)
48
48
  status Print a human-readable summary of current memory state
49
49
  check-stop Stop-hook check: emit a JSON allow/block decision
50
+ sync Re-scan for stack/integration/deployment facts and write any new
51
+ findings - runs automatically via load/check-stop; this is the
52
+ manual/debugging entry point
50
53
  dashboard <enable|disable> Turn the shared local dashboard on or off
51
54
  doctor [--force] Refresh memoryintel's own generated files (instructions.md, pointer
52
55
  blocks) to the current template wherever it's provably safe;