agent-working-memory 0.9.1 → 0.11.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.
Files changed (80) hide show
  1. package/README.md +89 -19
  2. package/dist/adapters/common.d.ts.map +1 -1
  3. package/dist/adapters/common.js +5 -1
  4. package/dist/adapters/common.js.map +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +2 -1
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli/migrate.js +29 -29
  9. package/dist/cli.js +405 -224
  10. package/dist/cli.js.map +1 -1
  11. package/dist/coordination/circuit-breaker.js +23 -23
  12. package/dist/core/salience.d.ts.map +1 -1
  13. package/dist/core/salience.js +10 -1
  14. package/dist/core/salience.js.map +1 -1
  15. package/dist/core/write-pipeline.d.ts.map +1 -1
  16. package/dist/core/write-pipeline.js +5 -1
  17. package/dist/core/write-pipeline.js.map +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/mcp.js +50 -3
  21. package/dist/mcp.js.map +1 -1
  22. package/dist/onboard/index.d.ts +68 -0
  23. package/dist/onboard/index.d.ts.map +1 -0
  24. package/dist/onboard/index.js +265 -0
  25. package/dist/onboard/index.js.map +1 -0
  26. package/dist/storage/factory.d.ts +1 -1
  27. package/dist/storage/factory.d.ts.map +1 -1
  28. package/dist/storage/factory.js +16 -2
  29. package/dist/storage/factory.js.map +1 -1
  30. package/dist/storage/pglite-schema.js +143 -143
  31. package/dist/storage/pglite.d.ts.map +1 -1
  32. package/dist/storage/pglite.js +8 -0
  33. package/dist/storage/pglite.js.map +1 -1
  34. package/dist/storage/postgres.d.ts +228 -0
  35. package/dist/storage/postgres.d.ts.map +1 -0
  36. package/dist/storage/postgres.js +1221 -0
  37. package/dist/storage/postgres.js.map +1 -0
  38. package/dist/version.d.ts +2 -0
  39. package/dist/version.d.ts.map +1 -0
  40. package/dist/version.js +27 -0
  41. package/dist/version.js.map +1 -0
  42. package/package.json +11 -1
  43. package/src/adapters/common.ts +5 -1
  44. package/src/api/index.ts +3 -3
  45. package/src/api/routes.ts +2 -1
  46. package/src/cli/migrate.ts +307 -307
  47. package/src/cli.ts +342 -273
  48. package/src/coordination/circuit-breaker.ts +83 -83
  49. package/src/coordination/failure-modes.ts +50 -50
  50. package/src/core/decay.ts +63 -63
  51. package/src/core/embeddings.ts +110 -110
  52. package/src/core/index.ts +5 -5
  53. package/src/core/logger.ts +36 -36
  54. package/src/core/ml-worker-entry.ts +194 -194
  55. package/src/core/ml-worker.ts +281 -281
  56. package/src/core/query-expander.ts +122 -122
  57. package/src/core/reranker.ts +119 -119
  58. package/src/core/salience.ts +10 -1
  59. package/src/core/write-pipeline.ts +5 -1
  60. package/src/engine/confidence.ts +120 -120
  61. package/src/engine/consolidation-scheduler.ts +242 -242
  62. package/src/engine/eval.ts +102 -102
  63. package/src/engine/eviction.ts +101 -101
  64. package/src/engine/index.ts +8 -8
  65. package/src/engine/retraction.ts +366 -366
  66. package/src/engine/staging.ts +74 -74
  67. package/src/index.ts +2 -1
  68. package/src/mcp.ts +62 -3
  69. package/src/onboard/index.ts +298 -0
  70. package/src/storage/factory.ts +15 -3
  71. package/src/storage/index.ts +3 -3
  72. package/src/storage/pglite-schema.ts +166 -166
  73. package/src/storage/pglite.ts +9 -0
  74. package/src/storage/postgres.ts +1475 -0
  75. package/src/storage/store.ts +80 -80
  76. package/src/types/agent.ts +67 -67
  77. package/src/types/checkpoint.ts +46 -46
  78. package/src/types/eval.ts +100 -100
  79. package/src/types/index.ts +6 -6
  80. package/src/version.ts +26 -0
@@ -1,74 +1,74 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Staging Buffer — weak signal handler.
5
- *
6
- * Observations that don't meet the salience threshold for active memory
7
- * go to staging. The staging buffer periodically:
8
- * 1. Checks staged engrams against active memory for resonance
9
- * 2. Promotes resonant engrams to active
10
- * 3. Discards expired engrams that never resonated
11
- *
12
- * Modeled on hippocampal consolidation — provisional encoding
13
- * that only persists if reactivated.
14
- */
15
-
16
- import type { IEngramStore as EngramStore } from '../storage/store.js';
17
- import type { ActivationEngine } from './activation.js';
18
-
19
- export class StagingBuffer {
20
- private store: EngramStore;
21
- private engine: ActivationEngine;
22
- private checkInterval: ReturnType<typeof setInterval> | null = null;
23
-
24
- constructor(store: EngramStore, engine: ActivationEngine) {
25
- this.store = store;
26
- this.engine = engine;
27
- }
28
-
29
- /**
30
- * Start the periodic staging check.
31
- */
32
- start(intervalMs: number = 60_000): void {
33
- this.checkInterval = setInterval(() => this.sweep(), intervalMs);
34
- }
35
-
36
- stop(): void {
37
- if (this.checkInterval) {
38
- clearInterval(this.checkInterval);
39
- this.checkInterval = null;
40
- }
41
- }
42
-
43
- /**
44
- * Sweep staged engrams: promote or discard.
45
- */
46
- async sweep(): Promise<{ promoted: string[]; discarded: string[] }> {
47
- const promoted: string[] = [];
48
- const discarded: string[] = [];
49
-
50
- const expired = await this.store.getExpiredStaging();
51
- for (const engram of expired) {
52
- // Check if this engram resonates with active memory
53
- const results = await this.engine.activate({
54
- agentId: engram.agentId,
55
- context: `${engram.concept} ${engram.content}`,
56
- limit: 3,
57
- minScore: 0.3,
58
- internal: true,
59
- });
60
-
61
- if (results.length > 0) {
62
- // Resonance found — promote to active
63
- await this.store.updateStage(engram.id, 'active');
64
- promoted.push(engram.id);
65
- } else {
66
- // No resonance — discard
67
- await this.store.deleteEngram(engram.id);
68
- discarded.push(engram.id);
69
- }
70
- }
71
-
72
- return { promoted, discarded };
73
- }
74
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Staging Buffer — weak signal handler.
5
+ *
6
+ * Observations that don't meet the salience threshold for active memory
7
+ * go to staging. The staging buffer periodically:
8
+ * 1. Checks staged engrams against active memory for resonance
9
+ * 2. Promotes resonant engrams to active
10
+ * 3. Discards expired engrams that never resonated
11
+ *
12
+ * Modeled on hippocampal consolidation — provisional encoding
13
+ * that only persists if reactivated.
14
+ */
15
+
16
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
17
+ import type { ActivationEngine } from './activation.js';
18
+
19
+ export class StagingBuffer {
20
+ private store: EngramStore;
21
+ private engine: ActivationEngine;
22
+ private checkInterval: ReturnType<typeof setInterval> | null = null;
23
+
24
+ constructor(store: EngramStore, engine: ActivationEngine) {
25
+ this.store = store;
26
+ this.engine = engine;
27
+ }
28
+
29
+ /**
30
+ * Start the periodic staging check.
31
+ */
32
+ start(intervalMs: number = 60_000): void {
33
+ this.checkInterval = setInterval(() => this.sweep(), intervalMs);
34
+ }
35
+
36
+ stop(): void {
37
+ if (this.checkInterval) {
38
+ clearInterval(this.checkInterval);
39
+ this.checkInterval = null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Sweep staged engrams: promote or discard.
45
+ */
46
+ async sweep(): Promise<{ promoted: string[]; discarded: string[] }> {
47
+ const promoted: string[] = [];
48
+ const discarded: string[] = [];
49
+
50
+ const expired = await this.store.getExpiredStaging();
51
+ for (const engram of expired) {
52
+ // Check if this engram resonates with active memory
53
+ const results = await this.engine.activate({
54
+ agentId: engram.agentId,
55
+ context: `${engram.concept} ${engram.content}`,
56
+ limit: 3,
57
+ minScore: 0.3,
58
+ internal: true,
59
+ });
60
+
61
+ if (results.length > 0) {
62
+ // Resonance found — promote to active
63
+ await this.store.updateStage(engram.id, 'active');
64
+ promoted.push(engram.id);
65
+ } else {
66
+ // No resonance — discard
67
+ await this.store.deleteEngram(engram.id);
68
+ discarded.push(engram.id);
69
+ }
70
+ }
71
+
72
+ return { promoted, discarded };
73
+ }
74
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
4
4
  import { resolve, dirname, basename } from 'node:path';
5
5
  import Fastify from 'fastify';
6
+ import { VERSION } from './version.js';
6
7
 
7
8
  // Load .env file if present (no external dependency)
8
9
  try {
@@ -202,7 +203,7 @@ async function main() {
202
203
 
203
204
  // Start server
204
205
  await app.listen({ port: PORT, host: '0.0.0.0' });
205
- console.log(`AgentWorkingMemory v0.8.5 listening on port ${PORT}`);
206
+ console.log(`AgentWorkingMemory v${VERSION} listening on port ${PORT}`);
206
207
 
207
208
  // Graceful shutdown
208
209
  const shutdown = async () => {
package/src/mcp.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  */
30
30
 
31
31
  import { readFileSync } from 'node:fs';
32
- import { resolve } from 'node:path';
32
+ import { resolve, basename } from 'node:path';
33
33
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
34
34
 
35
35
  // Load .env file if present (no external dependency)
@@ -75,6 +75,8 @@ import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
75
75
  import { embed } from './core/embeddings.js';
76
76
  import { startSidecar } from './hooks/sidecar.js';
77
77
  import { initLogger, log, getLogPath } from './core/logger.js';
78
+ import { VERSION } from './version.js';
79
+ import { buildPack, INTERVIEW_QUESTIONS } from './onboard/index.js';
78
80
  import { liteCompress, retrieveOriginal } from './core/lite-compress.js';
79
81
  import { queryPeerDecisions, formatPeerDecisions } from './coordination/peer-decisions.js';
80
82
 
@@ -86,7 +88,7 @@ const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO
86
88
 
87
89
  if (INCOGNITO) {
88
90
  console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
89
- const server = new McpServer({ name: 'agent-working-memory', version: '0.8.8' });
91
+ const server = new McpServer({ name: 'agent-working-memory', version: VERSION });
90
92
  const transport = new StdioServerTransport();
91
93
  server.connect(transport).catch(err => {
92
94
  console.error('MCP server failed:', err);
@@ -141,7 +143,7 @@ let coordDb: import('better-sqlite3').Database | null = null;
141
143
 
142
144
  const server = new McpServer({
143
145
  name: 'agent-working-memory',
144
- version: '0.8.8',
146
+ version: VERSION,
145
147
  });
146
148
 
147
149
  server.registerResource(
@@ -639,6 +641,15 @@ Use this at the start of every session or after compaction to pick up where you
639
641
  async () => {
640
642
  const checkpoint = await store.getCheckpoint(AGENT_ID);
641
643
 
644
+ // Cold-store nudge: an empty store means the agent has nothing to recall — offer to warm-start.
645
+ let coldStoreNudge = '';
646
+ try {
647
+ const activeCount = (await store.getEngramsByAgent(AGENT_ID)).length;
648
+ if (activeCount < 3) {
649
+ coldStoreNudge = `🌱 **This memory store is nearly empty (${activeCount} ${activeCount === 1 ? 'memory' : 'memories'}).** Warm-start it before other work: recall the "onboard a new project" skill and follow it — or call \`onboard_scan\` on this project's docs/repo, refine the results, and save them with \`memory_write\` (canonical). Recall becomes useful immediately.`;
650
+ }
651
+ } catch { /* count is best-effort */ }
652
+
642
653
  const now = Date.now();
643
654
  const idleMs = checkpoint
644
655
  ? now - checkpoint.auto.lastActivityAt.getTime()
@@ -717,6 +728,7 @@ Use this at the start of every session or after compaction to pick up where you
717
728
  : '';
718
729
  log(AGENT_ID, 'restore', `idle=${idleMin}min checkpoint=${!!checkpoint?.executionState} recalled=${recalledMemories.length} lastWrite=${lastWrite?.concept ?? 'none'}${fullConsolidationTriggered ? ' FULL_CONSOLIDATION' : ''}`);
719
730
  parts.push(`Idle: ${idleMin}min${consolidationNote}`);
731
+ if (coldStoreNudge) parts.push(`\n${coldStoreNudge}`);
720
732
 
721
733
  if (checkpoint?.executionState) {
722
734
  const s = checkpoint.executionState;
@@ -774,6 +786,53 @@ Use this at the start of every session or after compaction to pick up where you
774
786
  }
775
787
  );
776
788
 
789
+ // --- Onboarding Tools (warm-start a cold store) ---
790
+
791
+ server.tool(
792
+ 'onboard_scan',
793
+ `Scan a project's documentation + repository and return CANDIDATE memories to seed a cold store.
794
+
795
+ Use this when the store is empty / you're new to a project. The scan is deterministic
796
+ (real file contents, not guesses) — YOUR job is to refine the candidates into atomic,
797
+ recall-shaped memories, run the interview (onboard_questions), confirm with the user, then
798
+ save the good ones with memory_write (memory_class="canonical"). Nothing is saved by this tool.`,
799
+ {
800
+ docs: z.array(z.string()).optional()
801
+ .describe('Doc files/dirs to scan (Markdown/text). Defaults to the repo (or cwd).'),
802
+ repo: z.string().optional()
803
+ .describe('Repo root — also derives stack (package.json) + layout memories.'),
804
+ project: z.string().optional()
805
+ .describe('Project name (becomes a tag). Defaults to the repo/dir name.'),
806
+ purpose: z.string().optional()
807
+ .describe('The goal of this memory system, if known — becomes the anchor memory.'),
808
+ },
809
+ async (params) => {
810
+ const repo = params.repo;
811
+ const docs = params.docs && params.docs.length ? params.docs : [repo ?? process.cwd()];
812
+ const project = params.project ?? basename(resolve(repo ?? docs[0] ?? process.cwd()));
813
+ const pack = buildPack({ docs, repo, project, agentId: AGENT_ID, purpose: params.purpose });
814
+ log(AGENT_ID, 'onboard', `scan ${docs.join(',')}${repo ? ' +repo' : ''} → ${pack.memories.length} candidates`);
815
+ const text = [
816
+ `Scanned ${docs.join(', ')}${repo ? ` (+repo ${repo})` : ''} → ${pack.memories.length} CANDIDATE memories (NOT saved).`,
817
+ `Next: refine each into an atomic memory (lead with the fact + identifiers), run onboard_questions,`,
818
+ `confirm with the user, then save the good ones with memory_write (memory_class="canonical").`,
819
+ ``,
820
+ JSON.stringify({ project, candidates: pack.memories, questions: pack.questions }, null, 2),
821
+ ].join('\n');
822
+ return { content: [{ type: 'text' as const, text }] };
823
+ }
824
+ );
825
+
826
+ server.tool(
827
+ 'onboard_questions',
828
+ `Return the onboarding interview questions. Ask the user ONE at a time, starting with the
829
+ goal of the memory system, and ask follow-ups for clarity. Turn each answer into a canonical memory.`,
830
+ {},
831
+ async () => ({
832
+ content: [{ type: 'text' as const, text: INTERVIEW_QUESTIONS.map((q, i) => `${i + 1}. ${q}`).join('\n') }],
833
+ })
834
+ );
835
+
777
836
  // --- Task Management Tools ---
778
837
 
779
838
  server.tool(
@@ -0,0 +1,298 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * `awm onboard` — warm-start a cold memory store from a project's own knowledge.
5
+ *
6
+ * The cold-start problem: a fresh AWM store knows nothing, so recall returns
7
+ * nothing until enough interactions accumulate. Onboarding derives a seed set of
8
+ * memories up front — from documentation, the repository, and a short interview —
9
+ * so an agent can be useful on a project from the first turn.
10
+ *
11
+ * Design decisions (what makes this good vs. a vector-DB doc-dump):
12
+ * - It emits an **`awm import`-compatible file**, not direct writes — so it reuses
13
+ * the importer, and the file is the human review/edit surface ("make changes
14
+ * based on what's needed"). Flow: onboard -> review/edit -> `awm import`.
15
+ * - It extracts **atomic, recall-shaped memories** (concept = the fact/heading,
16
+ * content = the supporting text, tags = proj/topic/origin), not raw chunks.
17
+ * - Seed facts from the owner's own docs are **canonical** — they bypass the
18
+ * salience filter (which is designed to reject low-novelty observations and
19
+ * would otherwise silently drop half the seed).
20
+ * - It is **model-free** (this tier): deterministic Markdown/section + repo
21
+ * structure extraction, no API keys — preserving AWM's "everything local"
22
+ * property. An LLM-assisted extractor + live interview layer on top of this.
23
+ * - Ids are content-hashed, so re-running on changed docs is **idempotent**
24
+ * (import `--dedupe` drops unchanged rows; edited sections become new rows).
25
+ *
26
+ * The interview is emitted as questions in the review file (a model-free tier
27
+ * can't converse); answering them and re-running folds the answers into the seed.
28
+ * The anchor question is deliberately "What is the goal of this memory system?" —
29
+ * the answer shapes what knowledge is worth keeping.
30
+ */
31
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
32
+ import { resolve, join, extname, basename, relative, dirname } from 'node:path';
33
+ import { createHash } from 'node:crypto';
34
+
35
+ export interface OnboardMemory {
36
+ id: string;
37
+ agent_id: string;
38
+ concept: string;
39
+ content: string;
40
+ tags: string[];
41
+ confidence: number;
42
+ salience: number;
43
+ memory_class: 'canonical' | 'working';
44
+ }
45
+
46
+ export interface OnboardPack {
47
+ version: string;
48
+ kind: 'awm-onboard-pack';
49
+ project: string;
50
+ generated_for: string; // agent id
51
+ purpose: string | null; // answer to "what is the goal of this memory system?"
52
+ memories: OnboardMemory[];
53
+ questions: string[];
54
+ }
55
+
56
+ export interface OnboardOptions {
57
+ /** Files/dirs to scan for documentation (Markdown/text). */
58
+ docs: string[];
59
+ /** Repo root to derive structural memories from (package.json, README, layout). */
60
+ repo?: string;
61
+ project: string;
62
+ /** Target agent id stamped on every seed memory. */
63
+ agentId: string;
64
+ /** The project/memory-system goal (the anchor interview answer), if provided. */
65
+ purpose?: string;
66
+ }
67
+
68
+ const DOC_EXT = new Set(['.md', '.mdx', '.markdown', '.txt', '.rst']);
69
+ const MAX_CONTENT = 1200; // cap a section; the lead usually carries the fact
70
+
71
+ /** The high-value intake — what a domain expert would want captured that docs miss. */
72
+ export const INTERVIEW_QUESTIONS: string[] = [
73
+ 'What is the goal of this memory system — what should the agent get better at over time?',
74
+ 'What is the one-sentence description of this project and who it is for?',
75
+ 'What is the tech stack and the non-obvious tools/services it depends on?',
76
+ 'What naming conventions, patterns, or house style must the agent follow?',
77
+ 'What decisions are settled (and should NOT be re-litigated)?',
78
+ 'Who are the key people/systems, and how are they referred to?',
79
+ 'What are the known gotchas, footguns, or "here be dragons" areas?',
80
+ 'What does "done right" look like here — the definition of quality?',
81
+ ];
82
+
83
+ /**
84
+ * The onboarding skill — a procedure stored AS a canonical memory so a host agent
85
+ * (Codex, Claude Code, MWA) can *recall* it and run the interview itself. This is
86
+ * how the "LLM-assisted" tier works without AWM ever calling a model: the agent
87
+ * that's already there is the brain; AWM provides the procedure + the tools.
88
+ * Seeded by `awm setup`; recalled on a cold store (see the restore nudge).
89
+ */
90
+ export const ONBOARD_SKILL = {
91
+ concept: 'Skill: onboard a new project (warm-start protocol)',
92
+ content: [
93
+ 'When the memory store is empty or you are new to this project, warm-start it before doing other work:',
94
+ '1. Call the `onboard_scan` tool with the docs dir + repo path to get candidate memories (a deterministic scan — real file contents, not guesses).',
95
+ '2. Refine each candidate into an ATOMIC, recall-shaped memory: lead with the fact, keep it to one idea, and include concrete identifiers (file paths, table columns, function names, ticket IDs). Split fat sections into 2-3 crisp facts; drop noise.',
96
+ '3. Run the interview: call `onboard_questions`, then ask the user ONE question at a time starting with "What is the goal of this memory system?". Ask follow-ups for clarity when an answer is vague.',
97
+ '4. Propose the memory set you intend to save and get the user\'s confirmation (edit/drop as they direct).',
98
+ '5. Save each with `memory_write`, memory_class="canonical", tagged with project + topic + source. Stamp facts from the owner\'s own docs as verified/observed; mark your own inferences lower.',
99
+ 'Result: recall is useful from the next turn on. Re-run when the docs change to keep the seed fresh (supersede, don\'t duplicate).',
100
+ ].join('\n'),
101
+ tags: ['topic=skill', 'name=onboard', 'src=onboarding', 'intent=context'],
102
+ };
103
+
104
+ function slugify(s: string): string {
105
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
106
+ }
107
+
108
+ /** Deterministic short id from content → idempotent re-runs. */
109
+ function mkId(seed: string): string {
110
+ return 'onb-' + createHash('sha1').update(seed).digest('hex').slice(0, 16);
111
+ }
112
+
113
+ function clean(text: string): string {
114
+ const t = text
115
+ .replace(/```[\s\S]*?```/g, (m) => m.length > 300 ? '[code block]' : m) // drop huge code fences
116
+ .replace(/\r/g, '')
117
+ .replace(/\n{3,}/g, '\n\n')
118
+ .trim();
119
+ return t.length > MAX_CONTENT ? t.slice(0, MAX_CONTENT).trimEnd() + ' …' : t;
120
+ }
121
+
122
+ function mkMemory(
123
+ concept: string, content: string, topic: string, origin: string, opts: OnboardOptions,
124
+ ): OnboardMemory | null {
125
+ const c = clean(content);
126
+ const title = concept.trim();
127
+ // Skip empties and thin headings with no supporting prose.
128
+ if (!title || c.length < 24) return null;
129
+ return {
130
+ id: mkId(`${opts.agentId}::${title}::${c}`),
131
+ agent_id: opts.agentId,
132
+ concept: title.slice(0, 120),
133
+ content: c,
134
+ tags: [`proj=${opts.project}`, `topic=${topic}`, 'src=onboarding', `origin=${origin}`, 'intent=context'],
135
+ confidence: 0.7, // observed — derived from the owner's own docs
136
+ salience: 0.7, // canonical floor
137
+ memory_class: 'canonical',
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Split a Markdown/text doc into atomic memories, one per heading section.
143
+ * A section's memory is (heading, the prose under it up to the next heading).
144
+ * Untitled preamble before the first heading is captured as an "Overview".
145
+ */
146
+ export function scanMarkdown(text: string, origin: string, opts: OnboardOptions): OnboardMemory[] {
147
+ const out: OnboardMemory[] = [];
148
+ const lines = text.replace(/\r/g, '').split('\n');
149
+ let heading = '';
150
+ let buf: string[] = [];
151
+ const flush = () => {
152
+ const body = buf.join('\n').trim();
153
+ const concept = heading || `${basename(origin)} — overview`;
154
+ const topic = slugify(heading || basename(origin, extname(origin)));
155
+ const mem = mkMemory(concept, body, topic || 'doc', origin, opts);
156
+ if (mem) out.push(mem);
157
+ buf = [];
158
+ };
159
+ for (const line of lines) {
160
+ const h = /^(#{1,3})\s+(.*)$/.exec(line);
161
+ if (h) { flush(); heading = h[2].trim(); }
162
+ else buf.push(line);
163
+ }
164
+ flush();
165
+ return out;
166
+ }
167
+
168
+ /** Recursively collect documentation files under the given paths. */
169
+ function collectDocs(paths: string[]): string[] {
170
+ const files: string[] = [];
171
+ const walk = (p: string) => {
172
+ if (!existsSync(p)) return;
173
+ const st = statSync(p);
174
+ if (st.isDirectory()) {
175
+ if (/node_modules|\.git|dist|build/.test(p)) return;
176
+ for (const e of readdirSync(p)) walk(join(p, e));
177
+ } else if (DOC_EXT.has(extname(p).toLowerCase())) {
178
+ files.push(p);
179
+ }
180
+ };
181
+ for (const p of paths) walk(resolve(p));
182
+ return files;
183
+ }
184
+
185
+ /** Derive structural memories from a repository (package.json, README, layout). */
186
+ export function scanRepo(root: string, opts: OnboardOptions): OnboardMemory[] {
187
+ const out: OnboardMemory[] = [];
188
+ const r = resolve(root);
189
+ // package.json → stack + scripts
190
+ const pkgPath = join(r, 'package.json');
191
+ if (existsSync(pkgPath)) {
192
+ try {
193
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
194
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).slice(0, 25);
195
+ const scripts = Object.keys(pkg.scripts ?? {});
196
+ const parts = [
197
+ pkg.description ? `${pkg.name}: ${pkg.description}.` : `Project package: ${pkg.name} (v${pkg.version ?? '?'}).`,
198
+ deps.length ? `Key dependencies: ${deps.join(', ')}.` : '',
199
+ scripts.length ? `npm scripts: ${scripts.join(', ')}.` : '',
200
+ ].filter(Boolean).join(' ');
201
+ const m = mkMemory(`Project stack — ${pkg.name}`, parts, 'stack', 'package.json', opts);
202
+ if (m) out.push(m);
203
+ } catch { /* malformed package.json — skip */ }
204
+ }
205
+ // Top-level layout → a structure memory
206
+ try {
207
+ const entries = readdirSync(r)
208
+ .filter((e) => !/^\.|node_modules|dist|build/.test(e))
209
+ .filter((e) => { try { return statSync(join(r, e)).isDirectory(); } catch { return false; } });
210
+ if (entries.length) {
211
+ const m = mkMemory(
212
+ `Repository layout — ${opts.project}`,
213
+ `Top-level directories: ${entries.map((e) => `${e}/`).join(', ')}.`,
214
+ 'layout', 'repo-structure', opts,
215
+ );
216
+ if (m) out.push(m);
217
+ }
218
+ } catch { /* unreadable root — skip */ }
219
+ return out;
220
+ }
221
+
222
+ /** Assemble a reviewable, `awm import`-compatible pack from the sources. */
223
+ export function buildPack(opts: OnboardOptions): OnboardPack {
224
+ const memories: OnboardMemory[] = [];
225
+
226
+ // The anchor: the goal of the memory system, if the owner supplied it.
227
+ if (opts.purpose && opts.purpose.trim()) {
228
+ const g = mkMemory(
229
+ `Goal of this memory system — ${opts.project}`, opts.purpose.trim(), 'goal', 'interview', opts,
230
+ );
231
+ if (g) memories.push(g);
232
+ }
233
+
234
+ for (const file of collectDocs(opts.docs)) {
235
+ const rel = opts.repo ? relative(resolve(opts.repo), file) : basename(file);
236
+ try {
237
+ memories.push(...scanMarkdown(readFileSync(file, 'utf-8'), rel.replace(/\\/g, '/'), opts));
238
+ } catch { /* unreadable file — skip */ }
239
+ }
240
+ if (opts.repo) memories.push(...scanRepo(opts.repo, opts));
241
+
242
+ // Dedup by content-hash id (idempotent across re-runs and overlapping sources).
243
+ const seen = new Set<string>();
244
+ const deduped = memories.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
245
+
246
+ return {
247
+ version: '1',
248
+ kind: 'awm-onboard-pack',
249
+ project: opts.project,
250
+ generated_for: opts.agentId,
251
+ purpose: opts.purpose?.trim() || null,
252
+ memories: deduped,
253
+ questions: INTERVIEW_QUESTIONS,
254
+ };
255
+ }
256
+
257
+ /** A human-readable review surface: the owner edits this understanding, then imports the JSON. */
258
+ export function renderReview(pack: OnboardPack): string {
259
+ const lines: string[] = [];
260
+ lines.push(`# Onboarding review — ${pack.project}`);
261
+ lines.push('');
262
+ lines.push(`Generated ${pack.memories.length} candidate memories for agent \`${pack.generated_for}\`.`);
263
+ lines.push('Edit/delete below as needed, then import the JSON pack:');
264
+ lines.push('');
265
+ lines.push('```');
266
+ lines.push(`awm import <pack>.json --db <path> --dedupe`);
267
+ lines.push('```');
268
+ lines.push('');
269
+ lines.push('## Interview — answer these and re-run to enrich the seed');
270
+ lines.push('');
271
+ for (const q of pack.questions) lines.push(`- [ ] ${q}`);
272
+ lines.push('');
273
+ lines.push('## Candidate memories');
274
+ lines.push('');
275
+ for (const m of pack.memories) {
276
+ lines.push(`### ${m.concept}`);
277
+ lines.push(`*${m.tags.join(' · ')}* — class=${m.memory_class}`);
278
+ lines.push('');
279
+ lines.push(m.content);
280
+ lines.push('');
281
+ }
282
+ return lines.join('\n');
283
+ }
284
+
285
+ /**
286
+ * CLI entry: scan → write the import pack (JSON) + a review file (Markdown).
287
+ * Does not touch a store — the produced JSON is fed to `awm import`.
288
+ */
289
+ export function runOnboard(opts: OnboardOptions & { outDir: string }): { packPath: string; reviewPath: string; count: number } {
290
+ const pack = buildPack(opts);
291
+ mkdirSync(opts.outDir, { recursive: true });
292
+ const base = `onboard-${slugify(opts.project) || 'project'}`;
293
+ const packPath = join(opts.outDir, `${base}.pack.json`);
294
+ const reviewPath = join(opts.outDir, `${base}.review.md`);
295
+ writeFileSync(packPath, JSON.stringify(pack, null, 2), 'utf-8');
296
+ writeFileSync(reviewPath, renderReview(pack), 'utf-8');
297
+ return { packPath, reviewPath, count: pack.memories.length };
298
+ }
@@ -39,7 +39,7 @@
39
39
  import { existsSync, statSync } from 'node:fs';
40
40
  import type { IEngramStore } from './store.js';
41
41
 
42
- export type StoreBackend = 'sqlite' | 'pglite';
42
+ export type StoreBackend = 'sqlite' | 'pglite' | 'postgres';
43
43
 
44
44
  /**
45
45
  * Auto-detect the backend from on-disk state. Used when `AWM_STORE_BACKEND`
@@ -71,6 +71,7 @@ export function getConfiguredBackend(): StoreBackend {
71
71
  const normalized = raw.toLowerCase();
72
72
  if (normalized === 'pglite') return 'pglite';
73
73
  if (normalized === 'sqlite') return 'sqlite';
74
+ if (normalized === 'postgres') return 'postgres';
74
75
  console.warn(`Unknown AWM_STORE_BACKEND=${raw}; falling back to sqlite`);
75
76
  return 'sqlite';
76
77
  }
@@ -80,8 +81,11 @@ export function getConfiguredBackend(): StoreBackend {
80
81
  }
81
82
 
82
83
  export function getConfiguredPath(): string {
84
+ const backend = getConfiguredBackend();
85
+ // Postgres uses a connection URL, not an on-disk path.
86
+ if (backend === 'postgres') return process.env.AWM_DATABASE_URL ?? 'postgres://localhost:5432/awm';
83
87
  if (process.env.AWM_DB_PATH) return process.env.AWM_DB_PATH;
84
- return getConfiguredBackend() === 'pglite' ? 'memory-pglite' : 'memory.db';
88
+ return backend === 'pglite' ? 'memory-pglite' : 'memory.db';
85
89
  }
86
90
 
87
91
  /**
@@ -132,7 +136,15 @@ export async function openStore(): Promise<{
132
136
  const backend = getConfiguredBackend();
133
137
  const path = getConfiguredPath();
134
138
 
135
- warnIfBackendDisagreesWithDisk(backend, path);
139
+ // The on-disk mismatch warning only applies to file/dir backends.
140
+ if (backend !== 'postgres') warnIfBackendDisagreesWithDisk(backend, path);
141
+
142
+ if (backend === 'postgres') {
143
+ const { PostgresEngramStore } = await import('./postgres.js');
144
+ const store = new PostgresEngramStore(path);
145
+ await store.ready();
146
+ return { store: store as unknown as IEngramStore, backend, path };
147
+ }
136
148
 
137
149
  if (backend === 'pglite') {
138
150
  const { PGliteEngramStore } = await import('./pglite.js');
@@ -1,3 +1,3 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- export * from './sqlite.js';
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export * from './sqlite.js';