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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +91 -7
- package/dist/adapters/claudeCode.js +26 -7
- package/dist/cli.js +14 -0
- package/dist/commands/init.js +6 -4
- package/dist/commands/load.js +8 -0
- package/dist/commands/status.js +2 -1
- package/dist/commands/update.js +6 -3
- package/dist/core/detectedBlock.js +58 -0
- package/dist/core/factSync.js +60 -0
- package/dist/core/infraSignals.js +75 -0
- package/dist/core/knownFacts.js +53 -0
- package/package.json +1 -1
- package/skills/memoryintel/SKILL.md +3 -0
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Persistent, cross-session project memory for AI coding agents.",
|
|
9
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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`.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 #
|
|
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 {
|
|
74
|
+
return {};
|
|
66
75
|
if (signature === marker.lastFlaggedDiffSignature) {
|
|
67
|
-
return {
|
|
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 {
|
|
85
|
+
return {};
|
|
77
86
|
}
|
|
78
87
|
writeMarker(markerPath, { lastFlaggedDiffSignature: signature });
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
package/dist/commands/load.js
CHANGED
|
@@ -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));
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
@@ -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;
|