@moxxy/plugin-memory 0.27.0 → 0.28.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/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { parseMdFile, parseFrontmatter, renderFrontmatter } from './parse.js';
5
5
  export { recentExchanges, summarizeSession, type SessionFact } from './stm.js';
6
6
  export { TfIdfEmbedder, cosineSimilarity, tokenize } from './tfidf.js';
7
7
  export { EmbeddingIndex } from './embedding-cache.js';
8
+ export { UserModelStore, userModelTool, parseUserModel, serializeUserModel, renderInjectionBody, defaultUserModelPath, MAX_INJECTED_CHARS, USER_MODEL_OPEN, USER_MODEL_CLOSE, FIXED_SECTIONS, SECTION_TITLES, type UserModel, type UserModelSection, type UserModelSectionKey, type UserModelUpdateMode, } from './user-model.js';
8
9
  export { planConsolidation, consolidateMemory, buildMemoryConsolidatePlugin, memoryConsolidatePlugin, type ConsolidatePlan, type ConsolidateOptions, type ConsolidationOutcome, } from './consolidate.js';
9
10
  export interface BuildMemoryPluginOptions extends MemoryStoreOptions {
10
11
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+C,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EAAE,WAAW,EAAoB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGpF,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,uBAAuB,EACvB,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,UAAU,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC9E,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,4BAA4B,EAC5B,uBAAuB,EACvB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,GAC1B,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,wBAAyB,SAAQ,kBAAkB;CAAG;AAEvE,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,wBAA6B,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAuK7G;AAGD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,EAAE,MA0BvB,CAAC;AAGL,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+C,KAAK,MAAM,EAAwB,MAAM,YAAY,CAAC;AAC5G,OAAO,EAAE,WAAW,EAAoB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIpF,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,uBAAuB,EACvB,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,UAAU,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC9E,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,cAAc,EACd,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,4BAA4B,EAC5B,uBAAuB,EACvB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,GAC1B,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,wBAAyB,SAAQ,kBAAkB;CAAG;AAEvE,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,wBAA6B,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAgL7G;AAGD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,EAAE,MAqCvB,CAAC;AAGL,eAAe,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,15 +1,21 @@
1
1
  import { defineTool, defineEmbedder, definePlugin, z } from '@moxxy/sdk';
2
2
  import { MemoryStore, memoryTypeSchema } from './store.js';
3
3
  import { TfIdfEmbedder } from './tfidf.js';
4
+ import { UserModelStore, userModelTool } from './user-model.js';
4
5
  export { MemoryStore, memoryTypeSchema, memoryFrontmatterSchema, defaultMemoryDir, } from './store.js';
5
6
  export { parseMdFile, parseFrontmatter, renderFrontmatter } from './parse.js';
6
7
  export { recentExchanges, summarizeSession } from './stm.js';
7
8
  export { TfIdfEmbedder, cosineSimilarity, tokenize } from './tfidf.js';
8
9
  export { EmbeddingIndex } from './embedding-cache.js';
10
+ export { UserModelStore, userModelTool, parseUserModel, serializeUserModel, renderInjectionBody, defaultUserModelPath, MAX_INJECTED_CHARS, USER_MODEL_OPEN, USER_MODEL_CLOSE, FIXED_SECTIONS, SECTION_TITLES, } from './user-model.js';
9
11
  export { planConsolidation, consolidateMemory, buildMemoryConsolidatePlugin, memoryConsolidatePlugin, } from './consolidate.js';
10
12
  import { memoryConsolidatePlugin as memoryConsolidatePluginRef } from './consolidate.js';
11
13
  export function buildMemoryPlugin(opts = {}) {
12
14
  const store = new MemoryStore(opts);
15
+ // The persistent user model lives alongside the episodic memories in the same
16
+ // memory dir. Both the always-on injection hook and the update tool close over
17
+ // this one instance so the mtime cache is shared.
18
+ const userModel = new UserModelStore(opts.dir);
13
19
  const plugin = definePlugin({
14
20
  name: '@moxxy/plugin-memory',
15
21
  version: '0.0.0',
@@ -22,6 +28,10 @@ export function buildMemoryPlugin(opts = {}) {
22
28
  onInit: (ctx) => {
23
29
  ctx.services.register('memory', store);
24
30
  },
31
+ // ALWAYS-ON: prepend the delimited <user-model> block to every provider
32
+ // call when the file exists and has content. Idempotent (skips when the
33
+ // block is already present) and error-swallowing (never breaks a call).
34
+ onBeforeProviderCall: (req) => userModel.injectInto(req),
25
35
  },
26
36
  // The zero-dep TF-IDF embedder, contributed as a selectable embedder so it
27
37
  // sits in the same registry as openai/transformers/custom ones.
@@ -170,6 +180,7 @@ export function buildMemoryPlugin(opts = {}) {
170
180
  return { name: updated.frontmatter.name, updatedAt: updated.frontmatter.updatedAt };
171
181
  },
172
182
  }),
183
+ userModelTool(userModel),
173
184
  ],
174
185
  });
175
186
  return { plugin, store };
@@ -202,13 +213,26 @@ export const memoryPlugin = (() => {
202
213
  ...(base.embedders ? { embedders: base.embedders } : {}),
203
214
  tools: [...(base.tools ?? []), ...(consolidate.tools ?? [])],
204
215
  hooks: {
205
- ...consolidate.hooks,
206
216
  onInit: async (ctx) => {
207
217
  embeddersReg =
208
218
  ctx.services.get('embedders') ?? null;
209
219
  await base.hooks?.onInit?.(ctx);
210
220
  await consolidate.hooks?.onInit?.(ctx);
211
221
  },
222
+ // A Plugin has a single onBeforeProviderCall, but this composed plugin
223
+ // carries TWO: base's always-on user-model injection and consolidate's
224
+ // once-per-session nudge. Chain them so both run (injection prepends its
225
+ // block, the nudge appends its hint); either may return void to pass through.
226
+ onBeforeProviderCall: async (req, ctx) => {
227
+ let out;
228
+ const injected = await base.hooks?.onBeforeProviderCall?.(out ?? req, ctx);
229
+ if (injected)
230
+ out = injected;
231
+ const nudged = await consolidate.hooks?.onBeforeProviderCall?.(out ?? req, ctx);
232
+ if (nudged)
233
+ out = nudged;
234
+ return out;
235
+ },
212
236
  },
213
237
  });
214
238
  })();
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,101 @@
1
+ import { type ProviderRequest, type ToolDef } from '@moxxy/sdk';
2
+ /**
3
+ * The persistent USER MODEL — `~/.moxxy/memory/user-model.md`.
4
+ *
5
+ * A single, first-class Markdown file holding DURABLE facts about who the user
6
+ * is and how they work. Unlike episodic long-term memories (one file each,
7
+ * recalled on demand), the user model is ALWAYS injected into the system prompt
8
+ * as a delimited `<user-model>` block, and is updated ONLY through the
9
+ * deliberate, permission-prompted `memory_update_user_model` tool — never
10
+ * silently written by the loop.
11
+ *
12
+ * The file is a small set of fixed H2 sections (Identity / Preferences /
13
+ * Workflows / Context). Unknown sections a user hand-adds are parsed and
14
+ * preserved verbatim on the next write.
15
+ */
16
+ export declare const USER_MODEL_OPEN = "<user-model>";
17
+ export declare const USER_MODEL_CLOSE = "</user-model>";
18
+ /**
19
+ * Hard cap on the injected block body. The block rides EVERY provider call, so
20
+ * an unbounded user model would silently inflate every request. When the
21
+ * rendered sections exceed this, whole sections are dropped oldest-last (from
22
+ * the bottom) and a `(truncated)` marker is appended.
23
+ */
24
+ export declare const MAX_INJECTED_CHARS = 4000;
25
+ /** The four fixed sections, in canonical order. */
26
+ export declare const FIXED_SECTIONS: readonly ["Identity", "Preferences", "Workflows", "Context"];
27
+ /** Lowercase tool-facing section keys → canonical H2 titles. */
28
+ export declare const SECTION_TITLES: Record<UserModelSectionKey, string>;
29
+ export type UserModelSectionKey = 'identity' | 'preferences' | 'workflows' | 'context';
30
+ export type UserModelUpdateMode = 'replace' | 'append';
31
+ export interface UserModelSection {
32
+ readonly title: string;
33
+ content: string;
34
+ }
35
+ export interface UserModel {
36
+ /** Text before the first `## ` heading (kept verbatim — holds the template comment). */
37
+ preamble: string;
38
+ sections: UserModelSection[];
39
+ }
40
+ /**
41
+ * Parse the user-model Markdown into a preamble + ordered H2 sections. Any
42
+ * section title is preserved (not just the fixed four), so a user's hand-added
43
+ * `## Notes` survives the next tool write.
44
+ */
45
+ export declare function parseUserModel(text: string): UserModel;
46
+ /** Serialize back to Markdown, preserving the preamble and every section. */
47
+ export declare function serializeUserModel(model: UserModel): string;
48
+ /**
49
+ * Render the injected block body from a model, capped at {@link MAX_INJECTED_CHARS}.
50
+ * Empty sections are omitted. When the cap is hit, whole sections are dropped
51
+ * from the bottom (oldest-last) and a `(truncated)` marker is appended. Returns
52
+ * `''` when there is no non-empty content to inject.
53
+ */
54
+ export declare function renderInjectionBody(model: UserModel, max?: number): string;
55
+ /** Default on-disk location: `~/.moxxy/memory/user-model.md` (MOXXY_HOME-aware). */
56
+ export declare function defaultUserModelPath(): string;
57
+ /**
58
+ * The user-model store. One instance per plugin build; the injection hook and
59
+ * the update tool close over the same instance. `load()` is mtime-cached so the
60
+ * always-on injection does zero `readFile`s when the file is unchanged (a single
61
+ * `stat` per provider call); `update()` does an atomic read-modify-write under a
62
+ * mutex.
63
+ */
64
+ export declare class UserModelStore {
65
+ readonly path: string;
66
+ private readonly mutex;
67
+ private cached;
68
+ /** @param dir the memory directory (defaults to `~/.moxxy/memory`). */
69
+ constructor(dir?: string);
70
+ /**
71
+ * mtime-cached load. Returns `null` when the file is absent. On an unchanged
72
+ * file this only `stat`s — no `readFile` — so it's cheap to call on every
73
+ * provider request.
74
+ */
75
+ load(): Promise<UserModel | null>;
76
+ /** Raw file text (or `null` when absent) — used by the CLI viewer. */
77
+ readRaw(): Promise<string | null>;
78
+ /**
79
+ * Set (`replace`) or extend (`append`) one section, atomically. Creates the
80
+ * file with the commented template on first write. Bypasses the mtime cache
81
+ * on read so a concurrent external edit is not clobbered, and invalidates the
82
+ * cache after the write.
83
+ */
84
+ update(section: UserModelSectionKey, content: string, mode: UserModelUpdateMode): Promise<UserModel>;
85
+ /** Uncached disk read (used inside the update RMW). */
86
+ private readDisk;
87
+ /**
88
+ * The always-on injection: prepend a delimited `<user-model>` block to
89
+ * `req.system`. Returns the request unchanged (void) when the file is absent
90
+ * or has no non-empty content, when a `<user-model>` block is already present
91
+ * (side-channel calls re-enter this hook — inject exactly once), or on ANY
92
+ * error (injection must never break a provider call).
93
+ */
94
+ injectInto(req: ProviderRequest): Promise<ProviderRequest | void>;
95
+ }
96
+ /**
97
+ * `memory_update_user_model` — the ONLY writer of the persistent user model.
98
+ * Permission-prompted like `memory_save`, and isolated to the memory dir.
99
+ */
100
+ export declare function userModelTool(store: UserModelStore): ToolDef;
101
+ //# sourceMappingURL=user-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-model.d.ts","sourceRoot":"","sources":["../src/user-model.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,OAAO,EACb,MAAM,YAAY,CAAC;AAGpB;;;;;;;;;;;;;GAaG;AAGH,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAC9C,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,mDAAmD;AACnD,eAAO,MAAM,cAAc,8DAA+D,CAAC;AAE3F,gEAAgE;AAChE,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAK9D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AACvF,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,wFAAwF;IACxF,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,gBAAgB,EAAE,CAAC;CAC9B;AAkBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAoBtD;AAED,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAQ3D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,GAAE,MAA2B,GAAG,MAAM,CAkB9F;AAMD,oFAAoF;AACpF,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED;;;;;;GAMG;AACH,qBAAa,cAAc;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwB;IAC9C,OAAO,CAAC,MAAM,CAAsD;IAEpE,uEAAuE;gBAC3D,GAAG,CAAC,EAAE,MAAM;IAIxB;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAiBvC,sEAAsE;IAChE,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IASvC;;;;;OAKG;IACG,MAAM,CAAC,OAAO,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,SAAS,CAAC;IAkB1G,uDAAuD;YACzC,QAAQ;IAKtB;;;;;;OAMG;IACG,UAAU,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;CAgBxE;AAMD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CA2B5D"}
@@ -0,0 +1,261 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z, defineTool, createMutex, } from '@moxxy/sdk';
4
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
5
+ /**
6
+ * The persistent USER MODEL — `~/.moxxy/memory/user-model.md`.
7
+ *
8
+ * A single, first-class Markdown file holding DURABLE facts about who the user
9
+ * is and how they work. Unlike episodic long-term memories (one file each,
10
+ * recalled on demand), the user model is ALWAYS injected into the system prompt
11
+ * as a delimited `<user-model>` block, and is updated ONLY through the
12
+ * deliberate, permission-prompted `memory_update_user_model` tool — never
13
+ * silently written by the loop.
14
+ *
15
+ * The file is a small set of fixed H2 sections (Identity / Preferences /
16
+ * Workflows / Context). Unknown sections a user hand-adds are parsed and
17
+ * preserved verbatim on the next write.
18
+ */
19
+ // ── Block delimiters (also the idempotence sentinel) ────────────────────────
20
+ export const USER_MODEL_OPEN = '<user-model>';
21
+ export const USER_MODEL_CLOSE = '</user-model>';
22
+ /**
23
+ * Hard cap on the injected block body. The block rides EVERY provider call, so
24
+ * an unbounded user model would silently inflate every request. When the
25
+ * rendered sections exceed this, whole sections are dropped oldest-last (from
26
+ * the bottom) and a `(truncated)` marker is appended.
27
+ */
28
+ export const MAX_INJECTED_CHARS = 4000;
29
+ /** The four fixed sections, in canonical order. */
30
+ export const FIXED_SECTIONS = ['Identity', 'Preferences', 'Workflows', 'Context'];
31
+ /** Lowercase tool-facing section keys → canonical H2 titles. */
32
+ export const SECTION_TITLES = {
33
+ identity: 'Identity',
34
+ preferences: 'Preferences',
35
+ workflows: 'Workflows',
36
+ context: 'Context',
37
+ };
38
+ /** The commented template written when the file is first created. */
39
+ const TEMPLATE_PREAMBLE = `<!-- moxxy user model — durable facts about who the user is and how they work.
40
+ This file is ALWAYS injected into the system prompt and is updated ONLY via the
41
+ permission-prompted memory_update_user_model tool. Keep entries terse and stable. -->`;
42
+ function templateModel() {
43
+ return {
44
+ preamble: TEMPLATE_PREAMBLE,
45
+ sections: FIXED_SECTIONS.map((title) => ({ title, content: '' })),
46
+ };
47
+ }
48
+ // ── parse / serialize (round-trips unknown sections) ─────────────────────────
49
+ const HEADING_RE = /^##\s+(.+?)\s*$/;
50
+ /**
51
+ * Parse the user-model Markdown into a preamble + ordered H2 sections. Any
52
+ * section title is preserved (not just the fixed four), so a user's hand-added
53
+ * `## Notes` survives the next tool write.
54
+ */
55
+ export function parseUserModel(text) {
56
+ const preambleLines = [];
57
+ const sections = [];
58
+ let current = null;
59
+ let seenHeading = false;
60
+ for (const line of text.split('\n')) {
61
+ const m = HEADING_RE.exec(line);
62
+ if (m) {
63
+ seenHeading = true;
64
+ if (current)
65
+ sections.push({ title: current.title, content: current.body.join('\n').trim() });
66
+ current = { title: m[1].trim(), body: [] };
67
+ }
68
+ else if (!seenHeading) {
69
+ preambleLines.push(line);
70
+ }
71
+ else if (current) {
72
+ current.body.push(line);
73
+ }
74
+ }
75
+ if (current)
76
+ sections.push({ title: current.title, content: current.body.join('\n').trim() });
77
+ return { preamble: preambleLines.join('\n').trim(), sections };
78
+ }
79
+ /** Serialize back to Markdown, preserving the preamble and every section. */
80
+ export function serializeUserModel(model) {
81
+ const parts = [];
82
+ if (model.preamble.trim())
83
+ parts.push(model.preamble.trim());
84
+ for (const s of model.sections) {
85
+ const content = s.content.trim();
86
+ parts.push(content ? `## ${s.title}\n\n${content}` : `## ${s.title}`);
87
+ }
88
+ return parts.join('\n\n') + '\n';
89
+ }
90
+ /**
91
+ * Render the injected block body from a model, capped at {@link MAX_INJECTED_CHARS}.
92
+ * Empty sections are omitted. When the cap is hit, whole sections are dropped
93
+ * from the bottom (oldest-last) and a `(truncated)` marker is appended. Returns
94
+ * `''` when there is no non-empty content to inject.
95
+ */
96
+ export function renderInjectionBody(model, max = MAX_INJECTED_CHARS) {
97
+ const chunks = [];
98
+ let used = 0;
99
+ let truncated = false;
100
+ for (const s of model.sections) {
101
+ const content = s.content.trim();
102
+ if (!content)
103
+ continue;
104
+ const chunk = `## ${s.title}\n${content}`;
105
+ const cost = (chunks.length > 0 ? 2 : 0) + chunk.length; // 2 for the "\n\n" join
106
+ if (used + cost > max) {
107
+ truncated = true;
108
+ break;
109
+ }
110
+ chunks.push(chunk);
111
+ used += cost;
112
+ }
113
+ if (chunks.length === 0)
114
+ return '';
115
+ return chunks.join('\n\n') + (truncated ? '\n\n(truncated)' : '');
116
+ }
117
+ function isEnoent(err) {
118
+ return typeof err === 'object' && err !== null && err.code === 'ENOENT';
119
+ }
120
+ /** Default on-disk location: `~/.moxxy/memory/user-model.md` (MOXXY_HOME-aware). */
121
+ export function defaultUserModelPath() {
122
+ return moxxyPath('memory', 'user-model.md');
123
+ }
124
+ /**
125
+ * The user-model store. One instance per plugin build; the injection hook and
126
+ * the update tool close over the same instance. `load()` is mtime-cached so the
127
+ * always-on injection does zero `readFile`s when the file is unchanged (a single
128
+ * `stat` per provider call); `update()` does an atomic read-modify-write under a
129
+ * mutex.
130
+ */
131
+ export class UserModelStore {
132
+ path;
133
+ mutex = createMutex();
134
+ cached = null;
135
+ /** @param dir the memory directory (defaults to `~/.moxxy/memory`). */
136
+ constructor(dir) {
137
+ this.path = join(dir ?? moxxyPath('memory'), 'user-model.md');
138
+ }
139
+ /**
140
+ * mtime-cached load. Returns `null` when the file is absent. On an unchanged
141
+ * file this only `stat`s — no `readFile` — so it's cheap to call on every
142
+ * provider request.
143
+ */
144
+ async load() {
145
+ let mtimeMs;
146
+ try {
147
+ mtimeMs = (await fs.stat(this.path)).mtimeMs;
148
+ }
149
+ catch (err) {
150
+ if (isEnoent(err)) {
151
+ this.cached = null;
152
+ return null;
153
+ }
154
+ throw err;
155
+ }
156
+ if (this.cached && this.cached.mtimeMs === mtimeMs)
157
+ return this.cached.model;
158
+ const model = parseUserModel(await fs.readFile(this.path, 'utf8'));
159
+ this.cached = { mtimeMs, model };
160
+ return model;
161
+ }
162
+ /** Raw file text (or `null` when absent) — used by the CLI viewer. */
163
+ async readRaw() {
164
+ try {
165
+ return await fs.readFile(this.path, 'utf8');
166
+ }
167
+ catch (err) {
168
+ if (isEnoent(err))
169
+ return null;
170
+ throw err;
171
+ }
172
+ }
173
+ /**
174
+ * Set (`replace`) or extend (`append`) one section, atomically. Creates the
175
+ * file with the commented template on first write. Bypasses the mtime cache
176
+ * on read so a concurrent external edit is not clobbered, and invalidates the
177
+ * cache after the write.
178
+ */
179
+ async update(section, content, mode) {
180
+ const title = SECTION_TITLES[section];
181
+ return this.mutex.run(async () => {
182
+ const model = (await this.readDisk()) ?? templateModel();
183
+ let sec = model.sections.find((s) => s.title.toLowerCase() === title.toLowerCase());
184
+ if (!sec) {
185
+ sec = { title, content: '' };
186
+ model.sections.push(sec);
187
+ }
188
+ const incoming = content.trim();
189
+ const prior = sec.content.trim();
190
+ sec.content = mode === 'append' && prior ? `${prior}\n${incoming}`.trim() : incoming;
191
+ await writeFileAtomic(this.path, serializeUserModel(model));
192
+ this.cached = null; // next load() re-reads the fresh file
193
+ return model;
194
+ });
195
+ }
196
+ /** Uncached disk read (used inside the update RMW). */
197
+ async readDisk() {
198
+ const raw = await this.readRaw();
199
+ return raw === null ? null : parseUserModel(raw);
200
+ }
201
+ /**
202
+ * The always-on injection: prepend a delimited `<user-model>` block to
203
+ * `req.system`. Returns the request unchanged (void) when the file is absent
204
+ * or has no non-empty content, when a `<user-model>` block is already present
205
+ * (side-channel calls re-enter this hook — inject exactly once), or on ANY
206
+ * error (injection must never break a provider call).
207
+ */
208
+ async injectInto(req) {
209
+ try {
210
+ if ((req.system ?? '').includes(USER_MODEL_OPEN))
211
+ return; // idempotent
212
+ const model = await this.load();
213
+ if (!model)
214
+ return;
215
+ const body = renderInjectionBody(model);
216
+ if (!body)
217
+ return;
218
+ const block = `${USER_MODEL_OPEN}\n${body}\n${USER_MODEL_CLOSE}\n` +
219
+ 'The block above is durable context about the user, maintained across sessions via the ' +
220
+ '`memory_update_user_model` tool. Treat it as reliable background on who the user is and how they work.';
221
+ return { ...req, system: `${block}\n\n${req.system ?? ''}`.trimEnd() };
222
+ }
223
+ catch {
224
+ return; // any parse/fs error → leave the request untouched
225
+ }
226
+ }
227
+ }
228
+ // ── the update tool ──────────────────────────────────────────────────────────
229
+ const sectionSchema = z.enum(['identity', 'preferences', 'workflows', 'context']);
230
+ /**
231
+ * `memory_update_user_model` — the ONLY writer of the persistent user model.
232
+ * Permission-prompted like `memory_save`, and isolated to the memory dir.
233
+ */
234
+ export function userModelTool(store) {
235
+ return defineTool({
236
+ name: 'memory_update_user_model',
237
+ description: 'Update the persistent USER MODEL (~/.moxxy/memory/user-model.md), which is ALWAYS injected ' +
238
+ 'into your system prompt. PREFER this over memory_save for durable facts about WHO THE USER IS ' +
239
+ "and HOW THEY WORK — their identity, standing preferences, personal workflows, and lasting context — " +
240
+ 'NOT for episodic facts (use memory_save for a one-off answer or project detail). ' +
241
+ "mode 'replace' overwrites the section; 'append' adds to it. Keep each section terse.",
242
+ inputSchema: z.object({
243
+ section: sectionSchema,
244
+ content: z.string().max(2000),
245
+ mode: z.enum(['replace', 'append']).default('replace'),
246
+ }),
247
+ permission: { action: 'prompt' },
248
+ isolation: {
249
+ capabilities: {
250
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
251
+ net: { mode: 'none' },
252
+ timeMs: 5_000,
253
+ },
254
+ },
255
+ handler: async ({ section, content, mode }) => {
256
+ await store.update(section, content, mode);
257
+ return { section, mode, path: store.path };
258
+ },
259
+ });
260
+ }
261
+ //# sourceMappingURL=user-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-model.js","sourceRoot":"","sources":["../src/user-model.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/plugin-memory",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Journal-based long-term memory for moxxy. Markdown files at ~/.moxxy/memory/ with type/description frontmatter + an index. Plus STM selectors over the event log.",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -44,14 +44,14 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "zod": "^3.24.0",
47
- "@moxxy/sdk": "0.27.0"
47
+ "@moxxy/sdk": "0.28.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^22.10.0",
51
51
  "typescript": "^5.7.3",
52
52
  "vitest": "^2.1.8",
53
- "@moxxy/tsconfig": "0.0.0",
54
- "@moxxy/vitest-preset": "0.0.0"
53
+ "@moxxy/vitest-preset": "0.0.0",
54
+ "@moxxy/tsconfig": "0.0.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsc -p tsconfig.json",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { defineTool, defineEmbedder, definePlugin, z, type Plugin } from '@moxxy/sdk';
1
+ import { defineTool, defineEmbedder, definePlugin, z, type Plugin, type ProviderRequest } from '@moxxy/sdk';
2
2
  import { MemoryStore, memoryTypeSchema, type MemoryStoreOptions } from './store.js';
3
3
  import { TfIdfEmbedder } from './tfidf.js';
4
+ import { UserModelStore, userModelTool } from './user-model.js';
4
5
 
5
6
  export {
6
7
  MemoryStore,
@@ -18,6 +19,23 @@ export { parseMdFile, parseFrontmatter, renderFrontmatter } from './parse.js';
18
19
  export { recentExchanges, summarizeSession, type SessionFact } from './stm.js';
19
20
  export { TfIdfEmbedder, cosineSimilarity, tokenize } from './tfidf.js';
20
21
  export { EmbeddingIndex } from './embedding-cache.js';
22
+ export {
23
+ UserModelStore,
24
+ userModelTool,
25
+ parseUserModel,
26
+ serializeUserModel,
27
+ renderInjectionBody,
28
+ defaultUserModelPath,
29
+ MAX_INJECTED_CHARS,
30
+ USER_MODEL_OPEN,
31
+ USER_MODEL_CLOSE,
32
+ FIXED_SECTIONS,
33
+ SECTION_TITLES,
34
+ type UserModel,
35
+ type UserModelSection,
36
+ type UserModelSectionKey,
37
+ type UserModelUpdateMode,
38
+ } from './user-model.js';
21
39
  export {
22
40
  planConsolidation,
23
41
  consolidateMemory,
@@ -33,6 +51,10 @@ export interface BuildMemoryPluginOptions extends MemoryStoreOptions {}
33
51
 
34
52
  export function buildMemoryPlugin(opts: BuildMemoryPluginOptions = {}): { plugin: Plugin; store: MemoryStore } {
35
53
  const store = new MemoryStore(opts);
54
+ // The persistent user model lives alongside the episodic memories in the same
55
+ // memory dir. Both the always-on injection hook and the update tool close over
56
+ // this one instance so the mtime cache is shared.
57
+ const userModel = new UserModelStore(opts.dir);
36
58
  const plugin = definePlugin({
37
59
  name: '@moxxy/plugin-memory',
38
60
  version: '0.0.0',
@@ -45,6 +67,10 @@ export function buildMemoryPlugin(opts: BuildMemoryPluginOptions = {}): { plugin
45
67
  onInit: (ctx) => {
46
68
  ctx.services.register('memory', store);
47
69
  },
70
+ // ALWAYS-ON: prepend the delimited <user-model> block to every provider
71
+ // call when the file exists and has content. Idempotent (skips when the
72
+ // block is already present) and error-swallowing (never breaks a call).
73
+ onBeforeProviderCall: (req) => userModel.injectInto(req),
48
74
  },
49
75
  // The zero-dep TF-IDF embedder, contributed as a selectable embedder so it
50
76
  // sits in the same registry as openai/transformers/custom ones.
@@ -195,6 +221,7 @@ export function buildMemoryPlugin(opts: BuildMemoryPluginOptions = {}): { plugin
195
221
  return { name: updated.frontmatter.name, updatedAt: updated.frontmatter.updatedAt };
196
222
  },
197
223
  }),
224
+ userModelTool(userModel),
198
225
  ],
199
226
  });
200
227
  return { plugin, store };
@@ -232,13 +259,24 @@ export const memoryPlugin: Plugin = (() => {
232
259
  ...(base.embedders ? { embedders: base.embedders } : {}),
233
260
  tools: [...(base.tools ?? []), ...(consolidate.tools ?? [])],
234
261
  hooks: {
235
- ...consolidate.hooks,
236
262
  onInit: async (ctx) => {
237
263
  embeddersReg =
238
264
  ctx.services.get<{ tryGetActive(): unknown }>('embedders') ?? null;
239
265
  await base.hooks?.onInit?.(ctx);
240
266
  await consolidate.hooks?.onInit?.(ctx);
241
267
  },
268
+ // A Plugin has a single onBeforeProviderCall, but this composed plugin
269
+ // carries TWO: base's always-on user-model injection and consolidate's
270
+ // once-per-session nudge. Chain them so both run (injection prepends its
271
+ // block, the nudge appends its hint); either may return void to pass through.
272
+ onBeforeProviderCall: async (req, ctx) => {
273
+ let out: ProviderRequest | undefined;
274
+ const injected = await base.hooks?.onBeforeProviderCall?.(out ?? req, ctx);
275
+ if (injected) out = injected;
276
+ const nudged = await consolidate.hooks?.onBeforeProviderCall?.(out ?? req, ctx);
277
+ if (nudged) out = nudged;
278
+ return out;
279
+ },
242
280
  },
243
281
  });
244
282
  })();
@@ -0,0 +1,296 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { ProviderRequest } from '@moxxy/sdk';
6
+ import {
7
+ UserModelStore,
8
+ userModelTool,
9
+ parseUserModel,
10
+ serializeUserModel,
11
+ renderInjectionBody,
12
+ MAX_INJECTED_CHARS,
13
+ USER_MODEL_OPEN,
14
+ type UserModel,
15
+ } from './user-model.js';
16
+
17
+ let tmp: string;
18
+ const newStore = () => new UserModelStore(tmp);
19
+ const modelPath = () => path.join(tmp, 'user-model.md');
20
+
21
+ beforeEach(async () => {
22
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-um-'));
23
+ });
24
+ afterEach(async () => {
25
+ vi.restoreAllMocks();
26
+ await fs.rm(tmp, { recursive: true, force: true });
27
+ });
28
+
29
+ const req = (system?: string): ProviderRequest => ({
30
+ model: 'stub',
31
+ messages: [],
32
+ ...(system !== undefined ? { system } : {}),
33
+ });
34
+
35
+ // ── parse / serialize round-trip ─────────────────────────────────────────────
36
+
37
+ describe('parseUserModel / serializeUserModel', () => {
38
+ it('round-trips fixed sections with content', () => {
39
+ const text = '## Identity\n\nAlex, a backend dev.\n\n## Preferences\n\nTerse replies.\n';
40
+ const model = parseUserModel(text);
41
+ expect(model.sections.map((s) => s.title)).toEqual(['Identity', 'Preferences']);
42
+ expect(model.sections[0]!.content).toBe('Alex, a backend dev.');
43
+ // serialize → parse is stable
44
+ const reparsed = parseUserModel(serializeUserModel(model));
45
+ expect(reparsed.sections).toEqual(model.sections);
46
+ });
47
+
48
+ it('preserves an unknown (hand-added) section verbatim across a round-trip', () => {
49
+ const text =
50
+ '<!-- comment -->\n\n## Identity\n\nA.\n\n## Notes\n\nhand-written aside\nsecond line\n';
51
+ const model = parseUserModel(text);
52
+ expect(model.preamble).toBe('<!-- comment -->');
53
+ const notes = model.sections.find((s) => s.title === 'Notes');
54
+ expect(notes?.content).toBe('hand-written aside\nsecond line');
55
+ // Notes survives serialize → parse.
56
+ const reparsed = parseUserModel(serializeUserModel(model));
57
+ expect(reparsed.sections.find((s) => s.title === 'Notes')?.content).toBe(
58
+ 'hand-written aside\nsecond line',
59
+ );
60
+ expect(reparsed.preamble).toBe('<!-- comment -->');
61
+ });
62
+ });
63
+
64
+ // ── renderInjectionBody (size cap) ───────────────────────────────────────────
65
+
66
+ describe('renderInjectionBody', () => {
67
+ it('omits empty sections and returns "" when nothing has content', () => {
68
+ const empty: UserModel = {
69
+ preamble: '',
70
+ sections: [
71
+ { title: 'Identity', content: '' },
72
+ { title: 'Preferences', content: ' ' },
73
+ ],
74
+ };
75
+ expect(renderInjectionBody(empty)).toBe('');
76
+ });
77
+
78
+ it('renders only non-empty sections', () => {
79
+ const model: UserModel = {
80
+ preamble: '',
81
+ sections: [
82
+ { title: 'Identity', content: 'A.' },
83
+ { title: 'Preferences', content: '' },
84
+ { title: 'Workflows', content: 'W.' },
85
+ ],
86
+ };
87
+ const body = renderInjectionBody(model);
88
+ expect(body).toContain('## Identity\nA.');
89
+ expect(body).toContain('## Workflows\nW.');
90
+ expect(body).not.toContain('## Preferences');
91
+ });
92
+
93
+ it('drops whole sections oldest-last and appends a (truncated) marker past the cap', () => {
94
+ const big = 'x'.repeat(1500);
95
+ const model: UserModel = {
96
+ preamble: '',
97
+ sections: [
98
+ { title: 'Identity', content: big },
99
+ { title: 'Preferences', content: big },
100
+ { title: 'Workflows', content: big }, // 3×~1500 > 4000 → this one drops
101
+ ],
102
+ };
103
+ const body = renderInjectionBody(model);
104
+ expect(body).toContain('## Identity');
105
+ expect(body).toContain('## Preferences');
106
+ expect(body).not.toContain('## Workflows');
107
+ expect(body).toContain('(truncated)');
108
+ // The kept content stays within the cap (marker excluded).
109
+ expect(body.replace('\n\n(truncated)', '').length).toBeLessThanOrEqual(MAX_INJECTED_CHARS);
110
+ });
111
+ });
112
+
113
+ // ── mtime-cached load ────────────────────────────────────────────────────────
114
+
115
+ describe('UserModelStore.load (mtime cache)', () => {
116
+ it('returns null when the file is absent', async () => {
117
+ expect(await newStore().load()).toBeNull();
118
+ });
119
+
120
+ it('reads the file once, then serves an unchanged file from cache (no re-read)', async () => {
121
+ const store = newStore();
122
+ await store.update('identity', 'first', 'replace');
123
+ const spy = vi.spyOn(fs, 'readFile');
124
+ await store.load();
125
+ await store.load();
126
+ await store.load();
127
+ // Three loads of an unchanged file → a single readFile (mtime unchanged).
128
+ expect(spy).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it('invalidates the cache when the file changes (via update)', async () => {
132
+ const store = newStore();
133
+ await store.update('identity', 'first', 'replace');
134
+ const before = await store.load();
135
+ expect(before?.sections.find((s) => s.title === 'Identity')?.content).toBe('first');
136
+ await new Promise((r) => setTimeout(r, 10)); // ensure a distinct mtime
137
+ await store.update('identity', 'second', 'replace');
138
+ const after = await store.load();
139
+ expect(after?.sections.find((s) => s.title === 'Identity')?.content).toBe('second');
140
+ });
141
+
142
+ it('picks up an out-of-band write with a newer mtime', async () => {
143
+ const store = newStore();
144
+ await store.update('identity', 'first', 'replace');
145
+ await store.load(); // prime the cache
146
+ await new Promise((r) => setTimeout(r, 10));
147
+ await fs.writeFile(modelPath(), '## Identity\n\nrewritten\n');
148
+ expect((await store.load())?.sections.find((s) => s.title === 'Identity')?.content).toBe(
149
+ 'rewritten',
150
+ );
151
+ });
152
+ });
153
+
154
+ // ── update (template creation, replace/append) ───────────────────────────────
155
+
156
+ describe('UserModelStore.update', () => {
157
+ it('creates the file with the commented template on first write', async () => {
158
+ const store = newStore();
159
+ await store.update('identity', 'Alex', 'replace');
160
+ const raw = await fs.readFile(modelPath(), 'utf8');
161
+ expect(raw).toContain('<!-- moxxy user model');
162
+ // All four fixed sections exist (template), with Identity filled.
163
+ for (const h of ['## Identity', '## Preferences', '## Workflows', '## Context']) {
164
+ expect(raw).toContain(h);
165
+ }
166
+ expect(raw).toContain('Alex');
167
+ });
168
+
169
+ it('replace overwrites the section content', async () => {
170
+ const store = newStore();
171
+ await store.update('preferences', 'terse', 'replace');
172
+ await store.update('preferences', 'verbose', 'replace');
173
+ const m = await store.load();
174
+ expect(m?.sections.find((s) => s.title === 'Preferences')?.content).toBe('verbose');
175
+ });
176
+
177
+ it('append adds to existing content on its own line', async () => {
178
+ const store = newStore();
179
+ await store.update('workflows', 'uses pnpm', 'replace');
180
+ await store.update('workflows', 'rebases often', 'append');
181
+ const content = (await store.load())?.sections.find((s) => s.title === 'Workflows')?.content;
182
+ expect(content).toBe('uses pnpm\nrebases often');
183
+ });
184
+
185
+ it('append on an empty section behaves like replace', async () => {
186
+ const store = newStore();
187
+ await store.update('context', 'sole line', 'append');
188
+ expect((await store.load())?.sections.find((s) => s.title === 'Context')?.content).toBe(
189
+ 'sole line',
190
+ );
191
+ });
192
+
193
+ it('preserves an unknown section when a tool write touches a fixed one', async () => {
194
+ const store = newStore();
195
+ await store.update('identity', 'A', 'replace');
196
+ // Hand-add a section out of band, then update via the store.
197
+ const raw = await fs.readFile(modelPath(), 'utf8');
198
+ await fs.writeFile(modelPath(), raw + '\n## Notes\n\nkeep me\n');
199
+ await new Promise((r) => setTimeout(r, 10));
200
+ await store.update('preferences', 'terse', 'replace');
201
+ const raw2 = await fs.readFile(modelPath(), 'utf8');
202
+ expect(raw2).toContain('## Notes');
203
+ expect(raw2).toContain('keep me');
204
+ });
205
+ });
206
+
207
+ // ── injection hook ───────────────────────────────────────────────────────────
208
+
209
+ describe('UserModelStore.injectInto', () => {
210
+ it('is a no-op when the file is absent', async () => {
211
+ expect(await newStore().injectInto(req('BASE'))).toBeUndefined();
212
+ });
213
+
214
+ it('is a no-op when the file has only the (empty) template', async () => {
215
+ const store = newStore();
216
+ // A template with no filled sections should not inject an empty block.
217
+ await fs.writeFile(modelPath(), '<!-- c -->\n\n## Identity\n\n## Preferences\n');
218
+ expect(await store.injectInto(req('BASE'))).toBeUndefined();
219
+ });
220
+
221
+ it('prepends a delimited <user-model> block plus the durable-context note', async () => {
222
+ const store = newStore();
223
+ await store.update('identity', 'Alex, backend dev', 'replace');
224
+ const out = (await store.injectInto(req('BASE SYSTEM'))) as ProviderRequest;
225
+ expect(out.system!.startsWith(USER_MODEL_OPEN)).toBe(true);
226
+ expect(out.system).toContain('## Identity\nAlex, backend dev');
227
+ expect(out.system).toContain('</user-model>');
228
+ expect(out.system).toContain('memory_update_user_model');
229
+ // Original system prompt is retained, AFTER the block.
230
+ expect(out.system).toContain('BASE SYSTEM');
231
+ expect(out.system!.indexOf(USER_MODEL_OPEN)).toBeLessThan(out.system!.indexOf('BASE SYSTEM'));
232
+ });
233
+
234
+ it('injects even when req.system is undefined', async () => {
235
+ const store = newStore();
236
+ await store.update('identity', 'A', 'replace');
237
+ const out = (await store.injectInto(req())) as ProviderRequest;
238
+ expect(out.system).toContain('<user-model>');
239
+ });
240
+
241
+ it('is idempotent: skips when a <user-model> block is already present', async () => {
242
+ const store = newStore();
243
+ await store.update('identity', 'A', 'replace');
244
+ const first = (await store.injectInto(req('BASE'))) as ProviderRequest;
245
+ // Re-entering with the already-injected system must NOT double-inject.
246
+ const second = await store.injectInto(first);
247
+ expect(second).toBeUndefined();
248
+ // Only one opening delimiter present.
249
+ expect(first.system!.match(/<user-model>/g)).toHaveLength(1);
250
+ });
251
+
252
+ it('returns the request unchanged when parsing/reading throws', async () => {
253
+ const store = newStore();
254
+ await store.update('identity', 'A', 'replace');
255
+ vi.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('disk on fire'));
256
+ expect(await store.injectInto(req('BASE'))).toBeUndefined();
257
+ });
258
+ });
259
+
260
+ // ── the tool ─────────────────────────────────────────────────────────────────
261
+
262
+ describe('userModelTool', () => {
263
+ it('is prompt-permissioned and isolated to the memory dir', () => {
264
+ const tool = userModelTool(newStore());
265
+ expect(tool.name).toBe('memory_update_user_model');
266
+ expect(tool.permission).toEqual({ action: 'prompt' });
267
+ expect(tool.isolation?.capabilities?.fs?.write).toEqual(['~/.moxxy/memory/**']);
268
+ expect(tool.isolation?.capabilities?.net).toEqual({ mode: 'none' });
269
+ });
270
+
271
+ it('rejects an out-of-enum section and over-long content', () => {
272
+ const schema = userModelTool(newStore()).inputSchema;
273
+ expect(schema.safeParse({ section: 'identity', content: 'ok' }).success).toBe(true);
274
+ expect(schema.safeParse({ section: 'unknown', content: 'ok' }).success).toBe(false);
275
+ expect(schema.safeParse({ section: 'identity', content: 'x'.repeat(2001) }).success).toBe(false);
276
+ });
277
+
278
+ it("defaults mode to 'replace'", () => {
279
+ const schema = userModelTool(newStore()).inputSchema;
280
+ const parsed = schema.parse({ section: 'identity', content: 'A' });
281
+ expect(parsed.mode).toBe('replace');
282
+ });
283
+
284
+ it('writes through to the file and creates the template', async () => {
285
+ const store = newStore();
286
+ const tool = userModelTool(store);
287
+ const out = (await tool.handler(
288
+ { section: 'identity', content: 'Alex', mode: 'replace' },
289
+ {} as never,
290
+ )) as { section: string; mode: string; path: string };
291
+ expect(out.section).toBe('identity');
292
+ expect((await store.load())?.sections.find((s) => s.title === 'Identity')?.content).toBe('Alex');
293
+ const raw = await fs.readFile(modelPath(), 'utf8');
294
+ expect(raw).toContain('<!-- moxxy user model');
295
+ });
296
+ });
@@ -0,0 +1,293 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ z,
5
+ defineTool,
6
+ createMutex,
7
+ type Mutex,
8
+ type ProviderRequest,
9
+ type ToolDef,
10
+ } from '@moxxy/sdk';
11
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
12
+
13
+ /**
14
+ * The persistent USER MODEL — `~/.moxxy/memory/user-model.md`.
15
+ *
16
+ * A single, first-class Markdown file holding DURABLE facts about who the user
17
+ * is and how they work. Unlike episodic long-term memories (one file each,
18
+ * recalled on demand), the user model is ALWAYS injected into the system prompt
19
+ * as a delimited `<user-model>` block, and is updated ONLY through the
20
+ * deliberate, permission-prompted `memory_update_user_model` tool — never
21
+ * silently written by the loop.
22
+ *
23
+ * The file is a small set of fixed H2 sections (Identity / Preferences /
24
+ * Workflows / Context). Unknown sections a user hand-adds are parsed and
25
+ * preserved verbatim on the next write.
26
+ */
27
+
28
+ // ── Block delimiters (also the idempotence sentinel) ────────────────────────
29
+ export const USER_MODEL_OPEN = '<user-model>';
30
+ export const USER_MODEL_CLOSE = '</user-model>';
31
+
32
+ /**
33
+ * Hard cap on the injected block body. The block rides EVERY provider call, so
34
+ * an unbounded user model would silently inflate every request. When the
35
+ * rendered sections exceed this, whole sections are dropped oldest-last (from
36
+ * the bottom) and a `(truncated)` marker is appended.
37
+ */
38
+ export const MAX_INJECTED_CHARS = 4000;
39
+
40
+ /** The four fixed sections, in canonical order. */
41
+ export const FIXED_SECTIONS = ['Identity', 'Preferences', 'Workflows', 'Context'] as const;
42
+
43
+ /** Lowercase tool-facing section keys → canonical H2 titles. */
44
+ export const SECTION_TITLES: Record<UserModelSectionKey, string> = {
45
+ identity: 'Identity',
46
+ preferences: 'Preferences',
47
+ workflows: 'Workflows',
48
+ context: 'Context',
49
+ };
50
+
51
+ export type UserModelSectionKey = 'identity' | 'preferences' | 'workflows' | 'context';
52
+ export type UserModelUpdateMode = 'replace' | 'append';
53
+
54
+ export interface UserModelSection {
55
+ readonly title: string;
56
+ content: string;
57
+ }
58
+
59
+ export interface UserModel {
60
+ /** Text before the first `## ` heading (kept verbatim — holds the template comment). */
61
+ preamble: string;
62
+ sections: UserModelSection[];
63
+ }
64
+
65
+ /** The commented template written when the file is first created. */
66
+ const TEMPLATE_PREAMBLE = `<!-- moxxy user model — durable facts about who the user is and how they work.
67
+ This file is ALWAYS injected into the system prompt and is updated ONLY via the
68
+ permission-prompted memory_update_user_model tool. Keep entries terse and stable. -->`;
69
+
70
+ function templateModel(): UserModel {
71
+ return {
72
+ preamble: TEMPLATE_PREAMBLE,
73
+ sections: FIXED_SECTIONS.map((title) => ({ title, content: '' })),
74
+ };
75
+ }
76
+
77
+ // ── parse / serialize (round-trips unknown sections) ─────────────────────────
78
+
79
+ const HEADING_RE = /^##\s+(.+?)\s*$/;
80
+
81
+ /**
82
+ * Parse the user-model Markdown into a preamble + ordered H2 sections. Any
83
+ * section title is preserved (not just the fixed four), so a user's hand-added
84
+ * `## Notes` survives the next tool write.
85
+ */
86
+ export function parseUserModel(text: string): UserModel {
87
+ const preambleLines: string[] = [];
88
+ const sections: UserModelSection[] = [];
89
+ let current: { title: string; body: string[] } | null = null;
90
+ let seenHeading = false;
91
+
92
+ for (const line of text.split('\n')) {
93
+ const m = HEADING_RE.exec(line);
94
+ if (m) {
95
+ seenHeading = true;
96
+ if (current) sections.push({ title: current.title, content: current.body.join('\n').trim() });
97
+ current = { title: m[1]!.trim(), body: [] };
98
+ } else if (!seenHeading) {
99
+ preambleLines.push(line);
100
+ } else if (current) {
101
+ current.body.push(line);
102
+ }
103
+ }
104
+ if (current) sections.push({ title: current.title, content: current.body.join('\n').trim() });
105
+ return { preamble: preambleLines.join('\n').trim(), sections };
106
+ }
107
+
108
+ /** Serialize back to Markdown, preserving the preamble and every section. */
109
+ export function serializeUserModel(model: UserModel): string {
110
+ const parts: string[] = [];
111
+ if (model.preamble.trim()) parts.push(model.preamble.trim());
112
+ for (const s of model.sections) {
113
+ const content = s.content.trim();
114
+ parts.push(content ? `## ${s.title}\n\n${content}` : `## ${s.title}`);
115
+ }
116
+ return parts.join('\n\n') + '\n';
117
+ }
118
+
119
+ /**
120
+ * Render the injected block body from a model, capped at {@link MAX_INJECTED_CHARS}.
121
+ * Empty sections are omitted. When the cap is hit, whole sections are dropped
122
+ * from the bottom (oldest-last) and a `(truncated)` marker is appended. Returns
123
+ * `''` when there is no non-empty content to inject.
124
+ */
125
+ export function renderInjectionBody(model: UserModel, max: number = MAX_INJECTED_CHARS): string {
126
+ const chunks: string[] = [];
127
+ let used = 0;
128
+ let truncated = false;
129
+ for (const s of model.sections) {
130
+ const content = s.content.trim();
131
+ if (!content) continue;
132
+ const chunk = `## ${s.title}\n${content}`;
133
+ const cost = (chunks.length > 0 ? 2 : 0) + chunk.length; // 2 for the "\n\n" join
134
+ if (used + cost > max) {
135
+ truncated = true;
136
+ break;
137
+ }
138
+ chunks.push(chunk);
139
+ used += cost;
140
+ }
141
+ if (chunks.length === 0) return '';
142
+ return chunks.join('\n\n') + (truncated ? '\n\n(truncated)' : '');
143
+ }
144
+
145
+ function isEnoent(err: unknown): boolean {
146
+ return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT';
147
+ }
148
+
149
+ /** Default on-disk location: `~/.moxxy/memory/user-model.md` (MOXXY_HOME-aware). */
150
+ export function defaultUserModelPath(): string {
151
+ return moxxyPath('memory', 'user-model.md');
152
+ }
153
+
154
+ /**
155
+ * The user-model store. One instance per plugin build; the injection hook and
156
+ * the update tool close over the same instance. `load()` is mtime-cached so the
157
+ * always-on injection does zero `readFile`s when the file is unchanged (a single
158
+ * `stat` per provider call); `update()` does an atomic read-modify-write under a
159
+ * mutex.
160
+ */
161
+ export class UserModelStore {
162
+ readonly path: string;
163
+ private readonly mutex: Mutex = createMutex();
164
+ private cached: { mtimeMs: number; model: UserModel } | null = null;
165
+
166
+ /** @param dir the memory directory (defaults to `~/.moxxy/memory`). */
167
+ constructor(dir?: string) {
168
+ this.path = join(dir ?? moxxyPath('memory'), 'user-model.md');
169
+ }
170
+
171
+ /**
172
+ * mtime-cached load. Returns `null` when the file is absent. On an unchanged
173
+ * file this only `stat`s — no `readFile` — so it's cheap to call on every
174
+ * provider request.
175
+ */
176
+ async load(): Promise<UserModel | null> {
177
+ let mtimeMs: number;
178
+ try {
179
+ mtimeMs = (await fs.stat(this.path)).mtimeMs;
180
+ } catch (err) {
181
+ if (isEnoent(err)) {
182
+ this.cached = null;
183
+ return null;
184
+ }
185
+ throw err;
186
+ }
187
+ if (this.cached && this.cached.mtimeMs === mtimeMs) return this.cached.model;
188
+ const model = parseUserModel(await fs.readFile(this.path, 'utf8'));
189
+ this.cached = { mtimeMs, model };
190
+ return model;
191
+ }
192
+
193
+ /** Raw file text (or `null` when absent) — used by the CLI viewer. */
194
+ async readRaw(): Promise<string | null> {
195
+ try {
196
+ return await fs.readFile(this.path, 'utf8');
197
+ } catch (err) {
198
+ if (isEnoent(err)) return null;
199
+ throw err;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Set (`replace`) or extend (`append`) one section, atomically. Creates the
205
+ * file with the commented template on first write. Bypasses the mtime cache
206
+ * on read so a concurrent external edit is not clobbered, and invalidates the
207
+ * cache after the write.
208
+ */
209
+ async update(section: UserModelSectionKey, content: string, mode: UserModelUpdateMode): Promise<UserModel> {
210
+ const title = SECTION_TITLES[section];
211
+ return this.mutex.run(async () => {
212
+ const model = (await this.readDisk()) ?? templateModel();
213
+ let sec = model.sections.find((s) => s.title.toLowerCase() === title.toLowerCase());
214
+ if (!sec) {
215
+ sec = { title, content: '' };
216
+ model.sections.push(sec);
217
+ }
218
+ const incoming = content.trim();
219
+ const prior = sec.content.trim();
220
+ sec.content = mode === 'append' && prior ? `${prior}\n${incoming}`.trim() : incoming;
221
+ await writeFileAtomic(this.path, serializeUserModel(model));
222
+ this.cached = null; // next load() re-reads the fresh file
223
+ return model;
224
+ });
225
+ }
226
+
227
+ /** Uncached disk read (used inside the update RMW). */
228
+ private async readDisk(): Promise<UserModel | null> {
229
+ const raw = await this.readRaw();
230
+ return raw === null ? null : parseUserModel(raw);
231
+ }
232
+
233
+ /**
234
+ * The always-on injection: prepend a delimited `<user-model>` block to
235
+ * `req.system`. Returns the request unchanged (void) when the file is absent
236
+ * or has no non-empty content, when a `<user-model>` block is already present
237
+ * (side-channel calls re-enter this hook — inject exactly once), or on ANY
238
+ * error (injection must never break a provider call).
239
+ */
240
+ async injectInto(req: ProviderRequest): Promise<ProviderRequest | void> {
241
+ try {
242
+ if ((req.system ?? '').includes(USER_MODEL_OPEN)) return; // idempotent
243
+ const model = await this.load();
244
+ if (!model) return;
245
+ const body = renderInjectionBody(model);
246
+ if (!body) return;
247
+ const block =
248
+ `${USER_MODEL_OPEN}\n${body}\n${USER_MODEL_CLOSE}\n` +
249
+ 'The block above is durable context about the user, maintained across sessions via the ' +
250
+ '`memory_update_user_model` tool. Treat it as reliable background on who the user is and how they work.';
251
+ return { ...req, system: `${block}\n\n${req.system ?? ''}`.trimEnd() };
252
+ } catch {
253
+ return; // any parse/fs error → leave the request untouched
254
+ }
255
+ }
256
+ }
257
+
258
+ // ── the update tool ──────────────────────────────────────────────────────────
259
+
260
+ const sectionSchema = z.enum(['identity', 'preferences', 'workflows', 'context']);
261
+
262
+ /**
263
+ * `memory_update_user_model` — the ONLY writer of the persistent user model.
264
+ * Permission-prompted like `memory_save`, and isolated to the memory dir.
265
+ */
266
+ export function userModelTool(store: UserModelStore): ToolDef {
267
+ return defineTool({
268
+ name: 'memory_update_user_model',
269
+ description:
270
+ 'Update the persistent USER MODEL (~/.moxxy/memory/user-model.md), which is ALWAYS injected ' +
271
+ 'into your system prompt. PREFER this over memory_save for durable facts about WHO THE USER IS ' +
272
+ "and HOW THEY WORK — their identity, standing preferences, personal workflows, and lasting context — " +
273
+ 'NOT for episodic facts (use memory_save for a one-off answer or project detail). ' +
274
+ "mode 'replace' overwrites the section; 'append' adds to it. Keep each section terse.",
275
+ inputSchema: z.object({
276
+ section: sectionSchema,
277
+ content: z.string().max(2000),
278
+ mode: z.enum(['replace', 'append']).default('replace'),
279
+ }),
280
+ permission: { action: 'prompt' },
281
+ isolation: {
282
+ capabilities: {
283
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
284
+ net: { mode: 'none' },
285
+ timeMs: 5_000,
286
+ },
287
+ },
288
+ handler: async ({ section, content, mode }) => {
289
+ await store.update(section, content, mode);
290
+ return { section, mode, path: store.path };
291
+ },
292
+ });
293
+ }