@alisio/plugin-memory 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Gutiérrez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @alisio/plugin-memory
2
+
3
+ Engram-style persistent memory for [Alisio](https://github.com/GustavoGutierrez/alisio), shipped as a
4
+ built-in plugin of the `alisio` CLI (disable with `--disable-plugin memory`). It depends only on
5
+ `@alisio/sdk` and stores data through the SDK storage port (SQLite + FTS5 trigram, BM25 with recency).
6
+
7
+ - Observations with title, type, What/Why/Where/Learned body, project or personal scope and topic-key upserts.
8
+ - Tools: `memory_save`, `memory_search`, `memory_get`, `memory_context`, `memory_timeline`, `memory_pin`, `memory_forget`.
9
+ - Memory-aware compaction: extracts observations in the same summarizer call, archives the checkpoint
10
+ and injects budgeted recall; session summaries on exit; `/memory` command.
11
+
12
+ Configuration lives under `builtinPlugins.memory`. Docs: [Memory plugin](https://gustavogutierrez.github.io/alisio/memory).
13
+
14
+ Maintainer: Gustavo Gutiérrez · License: MIT
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ /** Options under `builtinPlugins.memory` in the Alisio configuration. */
3
+ export declare const memoryConfigSchema: z.ZodObject<{
4
+ enabled: z.ZodDefault<z.ZodBoolean>;
5
+ dbPath: z.ZodOptional<z.ZodString>;
6
+ injectBudgetTokens: z.ZodDefault<z.ZodNumber>;
7
+ recallLimit: z.ZodDefault<z.ZodNumber>;
8
+ autoSummary: z.ZodDefault<z.ZodBoolean>;
9
+ defaultScope: z.ZodDefault<z.ZodEnum<{
10
+ personal: "personal";
11
+ project: "project";
12
+ }>>;
13
+ }, z.core.$strict>;
14
+ export type MemoryConfig = z.infer<typeof memoryConfigSchema>;
package/dist/config.js ADDED
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ /** Options under `builtinPlugins.memory` in the Alisio configuration. */
3
+ export const memoryConfigSchema = z
4
+ .object({
5
+ enabled: z.boolean().default(true),
6
+ /** Defaults to `<state home>/memory.sqlite`; relative paths resolve from the config file. */
7
+ dbPath: z.string().min(1).optional(),
8
+ /** Token budget for injected memory context (session start and after compaction). */
9
+ injectBudgetTokens: z.number().int().min(100).max(20_000).default(1500),
10
+ /** Memories recalled after compaction. */
11
+ recallLimit: z.number().int().min(0).max(20).default(8),
12
+ /** Write a session summary via the provider on /clear, /exit or quit (TUI). */
13
+ autoSummary: z.boolean().default(true),
14
+ defaultScope: z.enum(["project", "personal"]).default("project"),
15
+ })
16
+ .strict();
@@ -0,0 +1,54 @@
1
+ import { type MemoryHit, type MemoryScope, type MemoryType } from "./types.ts";
2
+ /** Same estimate as the core (~4 characters per token), kept local to stay decoupled. */
3
+ export declare const estimateTokens: (text: string) => number;
4
+ export interface Observation {
5
+ title: string;
6
+ type: MemoryType;
7
+ what: string;
8
+ why?: string;
9
+ where?: string;
10
+ learned?: string;
11
+ topicKey?: string;
12
+ scope?: MemoryScope;
13
+ }
14
+ export declare function formatObservation(o: Observation): string;
15
+ export declare const redactPrivate: (text: string) => string;
16
+ /** Lowercase kebab `family/description`, at most two levels (Engram convention). */
17
+ export declare function normalizeTopicKey(key: string): string | undefined;
18
+ export declare const suggestTopicKey: (type: MemoryType, title: string) => string;
19
+ /**
20
+ * Quoted FTS5 terms (never raw user syntax). Terms need ≥3 characters because the index uses
21
+ * the trigram tokenizer.
22
+ */
23
+ export declare function ftsQuery(text: string, mode?: "all" | "any"): string | undefined;
24
+ /** BM25 (SQLite: lower is better) scaled by a 30-day recency decay and an access boost. */
25
+ export declare function rankScore(bm25: number, updatedAt: number, accessCount: number, now: number): number;
26
+ export declare const memoryLine: (m: MemoryHit, compact?: boolean) => string;
27
+ /** Heading + optional body + memory rows, never exceeding `budgetTokens`. */
28
+ export declare function renderInjection(input: {
29
+ heading: string;
30
+ body?: string;
31
+ memories: MemoryHit[];
32
+ budgetTokens: number;
33
+ }): string;
34
+ /** Engram's mem_context layout (with ids for memory_get), budgeted; pinned rows always kept. */
35
+ export declare function renderMemoryContext(data: {
36
+ project: string;
37
+ session?: string;
38
+ summary?: string;
39
+ prompts: string[];
40
+ pinned: MemoryHit[];
41
+ recent: MemoryHit[];
42
+ }, budgetTokens: number): string;
43
+ /** Validates model-extracted observations one by one; invalid items are dropped. */
44
+ export declare function parseObservations(value: unknown): Observation[];
45
+ export declare const OBSERVATIONS_FIELD = "array of durable observations worth remembering in future sessions:\n [{\"title\":\"verb + what\",\"type\":\"decision|bugfix|discovery|pattern|architecture|config|preference|learning\",\"what\":string,\"why\":string,\"where\":string,\"learned\":string,\"topic_key\":\"family/description\",\"scope\":\"project|personal\"}] ([] when nothing is durable)";
46
+ export declare const COMPACTION_GUIDANCE = "For \"observations\": include only durable knowledge (decisions, fixed bugs, non-obvious\ndiscoveries, conventions, configuration, user preferences). Use lowercase kebab topic keys like\n\"decision/memory-location\" so later updates replace earlier ones. Use scope \"personal\" only for\nuser preferences that apply across projects.";
47
+ export declare const MEMORY_PROTOCOL = "## Persistent memory (Alisio memory plugin)\nMemory tools write to Alisio's local memory store, never to the workspace.\nSave with memory_save right after: a bugfix, a decision, a non-obvious discovery, a config change,\nan established pattern or convention, or a learned user preference.\n- title: verb + what (e.g. \"Fixed lock leak in session store\"); type; scope (project by default,\n personal for cross-project user preferences); topic_key \"family/description\" when the subject\n may evolve (reuse it to update instead of duplicating).\n- content: what / why / where / learned.\nRecall: call memory_context first, then memory_search with 1-2 keywords, then memory_get for full\ndetails (memory_timeline shows neighbouring observations).\nMemory operations are bookkeeping, never the user-facing answer: always finish with the complete\nanswer to the user.";
48
+ export declare const SESSION_SUMMARY_SYSTEM = "You write the end-of-session summary for a coding-agent session and extract durable memories.\nReply with ONLY a JSON object:\n{\"summary\":{\"goal\":string,\"instructions\":string[],\"discoveries\":string[],\"accomplished\":string[],\"nextSteps\":string[],\"relevantFiles\":string[]},\n \"observations\": array of durable observations worth remembering in future sessions:\n [{\"title\":\"verb + what\",\"type\":\"decision|bugfix|discovery|pattern|architecture|config|preference|learning\",\"what\":string,\"why\":string,\"where\":string,\"learned\":string,\"topic_key\":\"family/description\",\"scope\":\"project|personal\"}] ([] when nothing is durable)}\nKeep paths, commands and identifiers exact. Do not invent facts. Be concise.\nFor \"observations\": include only durable knowledge (decisions, fixed bugs, non-obvious\ndiscoveries, conventions, configuration, user preferences). Use lowercase kebab topic keys like\n\"decision/memory-location\" so later updates replace earlier ones. Use scope \"personal\" only for\nuser preferences that apply across projects.";
49
+ /** Engram session summary sections; falls back to the raw text when JSON is invalid. */
50
+ export declare function parseSessionSummary(raw: string): {
51
+ structured: boolean;
52
+ text: string;
53
+ observations: Observation[];
54
+ };
package/dist/format.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Pure memory helpers: observation format, topic keys, FTS queries, ranking, budgeted context
3
+ * rendering and model-output parsing. No I/O here.
4
+ */
5
+ import { z } from "zod";
6
+ import { MEMORY_TYPES } from "./types.js";
7
+ /** Same estimate as the core (~4 characters per token), kept local to stay decoupled. */
8
+ export const estimateTokens = (text) => Math.ceil(text.length / 4);
9
+ export function formatObservation(o) {
10
+ return [
11
+ ["What", o.what],
12
+ ["Why", o.why],
13
+ ["Where", o.where],
14
+ ["Learned", o.learned],
15
+ ]
16
+ .filter(([, v]) => v?.trim())
17
+ .map(([k, v]) => `**${k}**: ${v?.trim()}`)
18
+ .join("\n");
19
+ }
20
+ export const redactPrivate = (text) => text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
21
+ const kebab = (text) => text
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+ /** Lowercase kebab `family/description`, at most two levels (Engram convention). */
26
+ export function normalizeTopicKey(key) {
27
+ const parts = key.split("/").map(kebab).filter(Boolean);
28
+ if (!parts.length)
29
+ return undefined;
30
+ const [family, ...rest] = parts;
31
+ const description = rest.join("-").slice(0, 80);
32
+ return description ? `${family}/${description}` : family;
33
+ }
34
+ const FAMILY = {
35
+ architecture: "architecture",
36
+ bugfix: "bug",
37
+ decision: "decision",
38
+ pattern: "pattern",
39
+ config: "config",
40
+ discovery: "discovery",
41
+ learning: "learning",
42
+ preference: "preference",
43
+ };
44
+ export const suggestTopicKey = (type, title) => normalizeTopicKey(`${FAMILY[type]}/${title}`) ?? FAMILY[type];
45
+ /**
46
+ * Quoted FTS5 terms (never raw user syntax). Terms need ≥3 characters because the index uses
47
+ * the trigram tokenizer.
48
+ */
49
+ export function ftsQuery(text, mode = "any") {
50
+ const tokens = [
51
+ ...new Set((text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter((t) => t.length >= 3)),
52
+ ].slice(0, 16);
53
+ return tokens.length
54
+ ? tokens.map((t) => `"${t}"`).join(mode === "all" ? " AND " : " OR ")
55
+ : undefined;
56
+ }
57
+ const DAY = 86_400_000;
58
+ /** BM25 (SQLite: lower is better) scaled by a 30-day recency decay and an access boost. */
59
+ export function rankScore(bm25, updatedAt, accessCount, now) {
60
+ const relevance = Math.max(-bm25, 1e-9);
61
+ const age = Math.max(0, now - updatedAt) / DAY;
62
+ return relevance * (0.6 + 0.4 * Math.exp(-age / 30)) * (1 + 0.05 * Math.log1p(accessCount));
63
+ }
64
+ export const memoryLine = (m, compact = false) => `- #${m.id} [${m.type}] **${m.title}**${!compact && m.snippet ? `: ${m.snippet}` : ""}`;
65
+ function clipLines(body, maxChars) {
66
+ if (body.length <= maxChars)
67
+ return body;
68
+ const marker = "\n[truncated]";
69
+ const room = maxChars - marker.length;
70
+ if (room <= 0)
71
+ return "";
72
+ let cut = body.slice(0, room);
73
+ const newline = cut.lastIndexOf("\n");
74
+ if (newline > room / 2)
75
+ cut = cut.slice(0, newline);
76
+ return `${cut}${marker}`;
77
+ }
78
+ /** Adds rows (full, else compact) while they fit in `room` characters. */
79
+ function rows(memories, room) {
80
+ const out = [];
81
+ let used = 0;
82
+ for (const m of memories) {
83
+ const full = memoryLine(m), compact = memoryLine(m, true);
84
+ const line = used + full.length + 1 <= room ? full : compact;
85
+ if (used + line.length + 1 > room)
86
+ break;
87
+ out.push(line);
88
+ used += line.length + 1;
89
+ }
90
+ return out;
91
+ }
92
+ /** Heading + optional body + memory rows, never exceeding `budgetTokens`. */
93
+ export function renderInjection(input) {
94
+ const limit = input.budgetTokens * 4;
95
+ const head = `[${input.heading}]\n`;
96
+ const memHeader = "\n## Relevant memories (call memory_get with an id for details)\n";
97
+ const reserve = input.memories.length ? Math.floor((limit - head.length) * 0.4) : 0;
98
+ const body = input.body?.trim()
99
+ ? clipLines(input.body.trim(), limit - head.length - reserve)
100
+ : "";
101
+ let out = `${head}${body}`;
102
+ const room = limit - out.length - memHeader.length;
103
+ const lines = room > 0 ? rows(input.memories, room) : [];
104
+ if (lines.length)
105
+ out += `${memHeader}${lines.join("\n")}`;
106
+ return out.slice(0, limit);
107
+ }
108
+ /** Engram's mem_context layout (with ids for memory_get), budgeted; pinned rows always kept. */
109
+ export function renderMemoryContext(data, budgetTokens) {
110
+ const limit = budgetTokens * 4;
111
+ const header = "[Memory context from previous sessions (Alisio memory). Call memory_get with an id for details.]\n";
112
+ const session = `### Session\nproject ${data.project}${data.session ? ` · session ${data.session.slice(0, 8)}` : ""}\n`;
113
+ let remaining = limit - header.length - session.length;
114
+ let pinned = "";
115
+ if (data.pinned.length) {
116
+ const title = "### Pinned\n";
117
+ const lines = rows(data.pinned, Math.max(0, Math.floor(remaining * 0.4) - title.length));
118
+ const compact = rows(data.pinned.map((m) => ({ ...m, snippet: "" })), Math.max(0, remaining - title.length));
119
+ const chosen = lines.length === data.pinned.length ? lines : compact;
120
+ if (chosen.length)
121
+ pinned = `${title}${chosen.join("\n")}\n`;
122
+ remaining -= pinned.length;
123
+ }
124
+ const summary = data.summary?.trim()
125
+ ? `${clipLines(`### Last Session Summary\n${data.summary.trim()}`, Math.floor(remaining * 0.45))}\n`
126
+ : "";
127
+ remaining -= summary.length;
128
+ let prompts = "";
129
+ if (data.prompts.length) {
130
+ const room = Math.floor(remaining * 0.35);
131
+ const lines = [];
132
+ let used = "### Recent User Prompts\n".length;
133
+ for (const p of data.prompts.slice(0, 5)) {
134
+ const text = p.replace(/\s+/g, " ").trim();
135
+ const line = `- ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`;
136
+ if (used + line.length + 1 > room)
137
+ continue;
138
+ lines.push(line);
139
+ used += line.length + 1;
140
+ }
141
+ if (lines.length)
142
+ prompts = `### Recent User Prompts\n${lines.join("\n")}\n`;
143
+ remaining -= prompts.length;
144
+ }
145
+ let recent = "";
146
+ const title = "### Recent Observations\n";
147
+ const pinnedIds = new Set(data.pinned.map((m) => m.id));
148
+ const lines = rows(data.recent.filter((m) => !pinnedIds.has(m.id)), remaining - title.length);
149
+ if (lines.length)
150
+ recent = `${title}${lines.join("\n")}\n`;
151
+ return `${header}${session}${summary}${prompts}${pinned}${recent}`.trimEnd().slice(0, limit);
152
+ }
153
+ const observationSchema = z.object({
154
+ title: z.string().trim().min(1).max(200),
155
+ type: z.enum(MEMORY_TYPES),
156
+ what: z.string().trim().min(1).max(4_000),
157
+ why: z.string().trim().max(2_000).optional(),
158
+ where: z.string().trim().max(1_000).optional(),
159
+ learned: z.string().trim().max(2_000).optional(),
160
+ topic_key: z.string().trim().max(120).optional(),
161
+ scope: z.enum(["project", "personal"]).optional(),
162
+ });
163
+ /** Validates model-extracted observations one by one; invalid items are dropped. */
164
+ export function parseObservations(value) {
165
+ if (!Array.isArray(value))
166
+ return [];
167
+ const out = [];
168
+ for (const item of value.slice(0, 20)) {
169
+ const parsed = observationSchema.safeParse(item);
170
+ if (!parsed.success)
171
+ continue;
172
+ const { topic_key, ...rest } = parsed.data;
173
+ const topicKey = topic_key ? normalizeTopicKey(topic_key) : undefined;
174
+ out.push(Object.fromEntries(Object.entries({ ...rest, ...(topicKey ? { topicKey } : {}) }).filter(([, v]) => v !== undefined && v !== "")));
175
+ }
176
+ return out;
177
+ }
178
+ export const OBSERVATIONS_FIELD = `array of durable observations worth remembering in future sessions:
179
+ [{"title":"verb + what","type":"decision|bugfix|discovery|pattern|architecture|config|preference|learning","what":string,"why":string,"where":string,"learned":string,"topic_key":"family/description","scope":"project|personal"}] ([] when nothing is durable)`;
180
+ export const COMPACTION_GUIDANCE = `For "observations": include only durable knowledge (decisions, fixed bugs, non-obvious
181
+ discoveries, conventions, configuration, user preferences). Use lowercase kebab topic keys like
182
+ "decision/memory-location" so later updates replace earlier ones. Use scope "personal" only for
183
+ user preferences that apply across projects.`;
184
+ export const MEMORY_PROTOCOL = `## Persistent memory (Alisio memory plugin)
185
+ Memory tools write to Alisio's local memory store, never to the workspace.
186
+ Save with memory_save right after: a bugfix, a decision, a non-obvious discovery, a config change,
187
+ an established pattern or convention, or a learned user preference.
188
+ - title: verb + what (e.g. "Fixed lock leak in session store"); type; scope (project by default,
189
+ personal for cross-project user preferences); topic_key "family/description" when the subject
190
+ may evolve (reuse it to update instead of duplicating).
191
+ - content: what / why / where / learned.
192
+ Recall: call memory_context first, then memory_search with 1-2 keywords, then memory_get for full
193
+ details (memory_timeline shows neighbouring observations).
194
+ Memory operations are bookkeeping, never the user-facing answer: always finish with the complete
195
+ answer to the user.`;
196
+ const listSchema = z.array(z.string().trim().max(2_000)).max(40).default([]);
197
+ const sessionSummarySchema = z.object({
198
+ summary: z.object({
199
+ goal: z.string().trim().min(1).max(2_000),
200
+ instructions: listSchema,
201
+ discoveries: listSchema,
202
+ accomplished: listSchema,
203
+ nextSteps: listSchema,
204
+ relevantFiles: listSchema,
205
+ }),
206
+ observations: z.array(z.unknown()).default([]),
207
+ });
208
+ export const SESSION_SUMMARY_SYSTEM = `You write the end-of-session summary for a coding-agent session and extract durable memories.
209
+ Reply with ONLY a JSON object:
210
+ {"summary":{"goal":string,"instructions":string[],"discoveries":string[],"accomplished":string[],"nextSteps":string[],"relevantFiles":string[]},
211
+ "observations": ${OBSERVATIONS_FIELD}}
212
+ Keep paths, commands and identifiers exact. Do not invent facts. Be concise.
213
+ ${COMPACTION_GUIDANCE}`;
214
+ const bullets = (items) => items.length ? items.map((i) => `- ${i}`).join("\n") : "- (none)";
215
+ /** Engram session summary sections; falls back to the raw text when JSON is invalid. */
216
+ export function parseSessionSummary(raw) {
217
+ try {
218
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw)?.[1];
219
+ const value = JSON.parse(fenced ?? raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
220
+ const parsed = sessionSummarySchema.safeParse(value);
221
+ if (!parsed.success)
222
+ throw parsed.error;
223
+ const s = parsed.data.summary;
224
+ return {
225
+ structured: true,
226
+ observations: parseObservations(parsed.data.observations),
227
+ text: [
228
+ `## Goal\n${s.goal}`,
229
+ `## Instructions\n${bullets(s.instructions)}`,
230
+ `## Discoveries\n${bullets(s.discoveries)}`,
231
+ `## Accomplished\n${bullets(s.accomplished)}`,
232
+ `## Next Steps\n${bullets(s.nextSteps)}`,
233
+ `## Relevant Files\n${bullets(s.relevantFiles)}`,
234
+ ].join("\n\n"),
235
+ };
236
+ }
237
+ catch {
238
+ return { structured: false, text: raw.trim(), observations: [] };
239
+ }
240
+ }
@@ -0,0 +1,9 @@
1
+ import { type Plugin } from "@alisio/sdk";
2
+ export interface MemoryPluginContext {
3
+ workspace: string;
4
+ stateHome: string;
5
+ /** Directory of the configuration file, for relative `dbPath`. */
6
+ configDir: string;
7
+ }
8
+ export type ArchiveOutcome = "confirmed" | "failed" | "unknown";
9
+ export declare function createMemoryPlugin(rawOptions: unknown, context: MemoryPluginContext): Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Built-in memory plugin (Engram-style). Everything memory-related lives in this directory;
3
+ * the core only exposes generic hooks. Disabled means: no store file, tools, prompt or hooks.
4
+ */
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { definePlugin } from "@alisio/sdk";
7
+ import { memoryConfigSchema } from "./config.js";
8
+ import { COMPACTION_GUIDANCE, formatObservation, MEMORY_PROTOCOL, memoryLine, OBSERVATIONS_FIELD, parseObservations, parseSessionSummary, renderInjection, SESSION_SUMMARY_SYSTEM, } from "./format.js";
9
+ import { projectId, SQLiteMemoryStore } from "./store.js";
10
+ import { memoryContextText, memoryTools } from "./tools.js";
11
+ const userTexts = (messages) => messages.flatMap((m) => (m.role === "user" && !m.summary && m.text.trim() ? [m.text] : []));
12
+ function transcript(messages, maxChars = 60_000) {
13
+ const lines = messages.map((m) => m.role === "user"
14
+ ? `${m.summary ? "CONTEXT" : "USER"}: ${m.text.slice(0, 4_000)}`
15
+ : m.role === "assistant"
16
+ ? [
17
+ m.text ? `ASSISTANT: ${m.text.slice(0, 4_000)}` : "",
18
+ ...m.calls.map((c) => `TOOL CALL ${c.id} ${c.name}: ${c.arguments.slice(0, 1_000)}`),
19
+ ]
20
+ .filter(Boolean)
21
+ .join("\n")
22
+ : `TOOL RESULT ${m.callId}: ${m.result.content
23
+ .map((c) => c.text)
24
+ .join("\n")
25
+ .slice(0, 1_500)}`);
26
+ const text = lines.join("\n");
27
+ return text.length > maxChars ? `[earlier messages omitted]\n${text.slice(-maxChars)}` : text;
28
+ }
29
+ export function createMemoryPlugin(rawOptions, context) {
30
+ const options = memoryConfigSchema.parse(rawOptions ?? {});
31
+ const dbPath = options.dbPath
32
+ ? isAbsolute(options.dbPath)
33
+ ? options.dbPath
34
+ : resolve(context.configDir, options.dbPath)
35
+ : join(context.stateHome, "memory.sqlite");
36
+ let store;
37
+ const stats = {
38
+ written: 0,
39
+ lastArchive: undefined,
40
+ lastSummary: "",
41
+ };
42
+ return definePlugin({
43
+ id: "memory",
44
+ name: "Memory",
45
+ description: "Persistent memory and memory-aware context compaction",
46
+ version: "0.1.0",
47
+ apiVersion: 1,
48
+ async setup(api) {
49
+ const project = await projectId(context.workspace);
50
+ const db = new SQLiteMemoryStore(api.storage.sqlite(dbPath));
51
+ store = db;
52
+ const deps = {
53
+ store: db,
54
+ project,
55
+ defaultScope: options.defaultScope,
56
+ budgetTokens: options.injectBudgetTokens,
57
+ onWrite: () => {
58
+ stats.written++;
59
+ refresh();
60
+ },
61
+ };
62
+ const refresh = () => {
63
+ const c = db.count(project);
64
+ api.ui.status("count", `mem ${c.project}${c.personal ? `+${c.personal}` : ""}`, [
65
+ `project ${project}: ${c.project} project + ${c.personal} personal memories`,
66
+ `${stats.written} writes in this process`,
67
+ `last compaction archive: ${stats.lastArchive ?? "none"}`,
68
+ ...(stats.lastSummary ? [`last session summary: ${stats.lastSummary}`] : []),
69
+ `store: ${dbPath}`,
70
+ ].join(" · "));
71
+ };
72
+ const saveAll = (observations, session, source) => {
73
+ const counts = { created: 0, updated: 0, duplicate: 0 };
74
+ for (const o of observations) {
75
+ const r = db.save({
76
+ project,
77
+ scope: o.scope ?? options.defaultScope,
78
+ type: o.type,
79
+ title: o.title,
80
+ content: formatObservation(o),
81
+ ...(o.topicKey ? { topicKey: o.topicKey } : {}),
82
+ session,
83
+ source,
84
+ });
85
+ counts[r.action]++;
86
+ }
87
+ return counts;
88
+ };
89
+ for (const tool of memoryTools(deps))
90
+ api.tools.register(tool);
91
+ api.context.register(async () => MEMORY_PROTOCOL);
92
+ api.compaction.register({
93
+ async beforeCompact() {
94
+ return {
95
+ instructions: COMPACTION_GUIDANCE,
96
+ outputFields: { observations: OBSERVATIONS_FIELD },
97
+ };
98
+ },
99
+ async afterCompact(result) {
100
+ for (const text of userTexts(result.messages).slice(-10))
101
+ db.recordPrompt(project, result.sessionId, text);
102
+ const memories = saveAll(parseObservations(result.extracted.observations), result.sessionId, "compaction");
103
+ // Deterministic archive of the checkpoint as the session summary, confirmed by readback.
104
+ let archive;
105
+ try {
106
+ const stored = db.saveSummary(project, result.sessionId, result.checkpointText);
107
+ archive = db.summaryOf(result.sessionId) === stored ? "confirmed" : "unknown";
108
+ }
109
+ catch {
110
+ archive = "failed";
111
+ }
112
+ stats.lastArchive = archive;
113
+ const query = result.checkpoint
114
+ ? [result.checkpoint.goal, ...result.checkpoint.nextSteps].join(" ")
115
+ : result.checkpointText.slice(0, 400);
116
+ const recalled = options.recallLimit
117
+ ? db.search(project, query, { mode: "any", limit: options.recallLimit })
118
+ : [];
119
+ refresh();
120
+ return {
121
+ ...(recalled.length
122
+ ? {
123
+ injectContext: renderInjection({
124
+ heading: "Recovered memory (Alisio memory plugin)",
125
+ memories: recalled,
126
+ budgetTokens: options.injectBudgetTokens,
127
+ }),
128
+ }
129
+ : {}),
130
+ report: {
131
+ summary: `memory: +${memories.created} new, ${memories.updated} updated, ${memories.duplicate} duplicate · archive ${archive} · ${recalled.length} recalled`,
132
+ archive,
133
+ memories,
134
+ recalled: recalled.length,
135
+ },
136
+ };
137
+ },
138
+ });
139
+ api.session.onStart(async (info) => {
140
+ refresh();
141
+ return memoryContextText(deps, info.sessionId);
142
+ });
143
+ api.session.onEnd(async (info) => {
144
+ const prompts = userTexts(info.messages);
145
+ const meaningful = prompts.length > 0 &&
146
+ info.messages.some((m) => m.role === "assistant" && (m.text.trim() || m.calls.length));
147
+ if (!options.autoSummary || !meaningful)
148
+ return;
149
+ for (const text of prompts.slice(-10))
150
+ db.recordPrompt(project, info.sessionId, text);
151
+ try {
152
+ const raw = await api.model.complete({
153
+ system: SESSION_SUMMARY_SYSTEM,
154
+ messages: [
155
+ { role: "user", text: `<transcript>\n${transcript(info.messages)}\n</transcript>` },
156
+ ],
157
+ maxTokens: 2048,
158
+ sessionId: info.sessionId,
159
+ signal: info.signal,
160
+ });
161
+ const parsed = parseSessionSummary(raw);
162
+ if (parsed.text)
163
+ db.saveSummary(project, info.sessionId, parsed.text);
164
+ const counts = saveAll(parsed.observations, info.sessionId, "session_summary");
165
+ stats.lastSummary = `saved (${parsed.structured ? "structured" : "text-only"}, +${counts.created} memories)`;
166
+ }
167
+ catch (error) {
168
+ stats.lastSummary = `failed (${error instanceof Error ? error.message : String(error)})`;
169
+ throw error;
170
+ }
171
+ finally {
172
+ refresh();
173
+ }
174
+ });
175
+ api.commands.register("memory", async (args) => {
176
+ const [verb = "", ...rest] = args.trim().split(/\s+/);
177
+ const target = Number(rest[0]);
178
+ const needsId = ["show", "forget", "pin", "unpin"].includes(verb);
179
+ if (needsId && !Number.isInteger(target))
180
+ return `Usage: /memory ${verb} <id>`;
181
+ if (verb === "show") {
182
+ const r = db.get(target, project);
183
+ if (!r)
184
+ return `Memory #${target} not found.`;
185
+ return [
186
+ `**#${r.id} [${r.type}] ${r.title}**`,
187
+ "",
188
+ `scope ${r.scope}${r.topicKey ? ` · topic \`${r.topicKey}\`` : ""} · revisions ${r.revisionCount} · duplicates ${r.duplicateCount}${r.pinned ? " · pinned" : ""}`,
189
+ "",
190
+ r.content,
191
+ ].join("\n");
192
+ }
193
+ if (verb === "forget") {
194
+ const ok = db.forget(target, project);
195
+ if (ok)
196
+ deps.onWrite();
197
+ return ok ? `Forgot memory #${target}.` : `Memory #${target} not found.`;
198
+ }
199
+ if (verb === "pin" || verb === "unpin") {
200
+ const ok = db.pin(target, project, verb === "pin");
201
+ if (ok)
202
+ deps.onWrite();
203
+ return ok ? `Memory #${target} ${verb}ned.` : `Memory #${target} not found.`;
204
+ }
205
+ const c = db.count(project);
206
+ if (!args.trim()) {
207
+ const recent = db.recent(project, 15);
208
+ return [
209
+ `**Memory** · ${c.project} project + ${c.personal} personal · project \`${project}\``,
210
+ "",
211
+ ...(recent.length ? recent.map((m) => memoryLine(m, true)) : ["No memories yet."]),
212
+ "",
213
+ "`/memory <query>` search · `/memory show|forget|pin|unpin <id>`",
214
+ ].join("\n");
215
+ }
216
+ let hits = db.search(project, args, { limit: 15 });
217
+ if (!hits.length)
218
+ hits = db.search(project, args, { limit: 15, mode: "any" });
219
+ return hits.length
220
+ ? [`**Memory search:** ${args}`, "", ...hits.map((m) => memoryLine(m))].join("\n")
221
+ : `No memories match "${args}".`;
222
+ }, {
223
+ description: "List, search, show, pin or forget memories",
224
+ argumentHint: "[query | show <id> | forget <id> | pin <id>]",
225
+ });
226
+ refresh();
227
+ },
228
+ dispose() {
229
+ store?.close();
230
+ store = undefined;
231
+ },
232
+ });
233
+ }
@@ -0,0 +1,42 @@
1
+ import type { SqlDatabase } from "@alisio/sdk";
2
+ import type { MemoryHit, MemoryInput, MemoryRecord, MemoryStore, SaveAction, SearchOptions, SessionSummary } from "./types.ts";
3
+ export declare const MAX_OBSERVATION_LENGTH = 50000;
4
+ /** Stable project id: `<basename>-<sha256(realpath)[0:8]>` of the git root or cwd. */
5
+ export declare function projectId(path: string): Promise<string>;
6
+ export declare class SQLiteMemoryStore implements MemoryStore {
7
+ readonly db: SqlDatabase;
8
+ private now;
9
+ private dedupWindowMs;
10
+ /** `db` comes from the SDK storage port (`api.storage.sqlite`) or any compatible adapter. */
11
+ constructor(db: SqlDatabase, options?: {
12
+ now?: () => number;
13
+ dedupWindowMs?: number;
14
+ });
15
+ private migrate;
16
+ save(input: MemoryInput): {
17
+ id: number;
18
+ action: SaveAction;
19
+ };
20
+ search(project: string, query: string, options?: SearchOptions): MemoryHit[];
21
+ recent(project: string, limit?: number): MemoryHit[];
22
+ pinned(project: string, limit?: number): MemoryHit[];
23
+ get(id: number, project: string): MemoryRecord | undefined;
24
+ timeline(id: number, project: string, before?: number, after?: number): MemoryHit[];
25
+ pin(id: number, project: string, pinned: boolean): boolean;
26
+ forget(id: number, project: string, hard?: boolean): boolean;
27
+ count(project: string): {
28
+ project: number;
29
+ personal: number;
30
+ };
31
+ recordPrompt(project: string, session: string, content: string): void;
32
+ recentPrompts(project: string, limit?: number): {
33
+ session: string;
34
+ content: string;
35
+ createdAt: number;
36
+ }[];
37
+ saveSummary(project: string, session: string, content: string): string;
38
+ lastSummary(project: string): SessionSummary | undefined;
39
+ /** Session summary of one session, used to confirm an archive write. */
40
+ summaryOf(session: string): string | undefined;
41
+ close(): void;
42
+ }
package/dist/store.js ADDED
@@ -0,0 +1,277 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { ftsQuery, normalizeTopicKey, rankScore, redactPrivate } from "./format.js";
5
+ /** Memory schema versions live at 100+ so the file can be shared with the session store. */
6
+ const MEMORY_SCHEMA_VERSION = 100;
7
+ export const MAX_OBSERVATION_LENGTH = 50_000;
8
+ const TRUNCATED = "... [truncated]";
9
+ const SNIPPET = 300;
10
+ /** Stable project id: `<basename>-<sha256(realpath)[0:8]>` of the git root or cwd. */
11
+ export async function projectId(path) {
12
+ const real = await realpath(path);
13
+ const name = basename(real).replace(/[^\w.-]/g, "-") || "root";
14
+ return `${name}-${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
15
+ }
16
+ const normalize = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
17
+ const cap = (text, max) => text.length > max ? `${text.slice(0, max - TRUNCATED.length)}${TRUNCATED}` : text;
18
+ const hit = (r) => {
19
+ const flat = r.content.replace(/\s+/g, " ").trim();
20
+ return {
21
+ id: r.id,
22
+ type: r.type,
23
+ title: r.title,
24
+ snippet: flat.length > SNIPPET ? `${flat.slice(0, SNIPPET)}…` : flat,
25
+ scope: r.scope,
26
+ ...(r.topic_key ? { topicKey: r.topic_key } : {}),
27
+ updatedAt: r.updated_at,
28
+ ...(r.pinned ? { pinned: true } : {}),
29
+ };
30
+ };
31
+ const VISIBLE = "o.deleted_at IS NULL AND (o.project=? OR o.scope='personal')";
32
+ export class SQLiteMemoryStore {
33
+ db;
34
+ now;
35
+ dedupWindowMs;
36
+ /** `db` comes from the SDK storage port (`api.storage.sqlite`) or any compatible adapter. */
37
+ constructor(db, options = {}) {
38
+ this.now = options.now ?? Date.now;
39
+ this.dedupWindowMs = options.dedupWindowMs ?? 15 * 60_000;
40
+ this.db = db;
41
+ this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);");
42
+ this.migrate();
43
+ }
44
+ migrate() {
45
+ if (this.db.prepare("SELECT 1 FROM schema_migrations WHERE version=?").get(MEMORY_SCHEMA_VERSION))
46
+ return;
47
+ this.db.transaction(() => {
48
+ this.db.exec(`
49
+ CREATE TABLE IF NOT EXISTS observations(id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ session_id TEXT, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
51
+ tool_name TEXT, project TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'project',
52
+ topic_key TEXT, normalized_hash TEXT NOT NULL,
53
+ revision_count INTEGER NOT NULL DEFAULT 1, duplicate_count INTEGER NOT NULL DEFAULT 1,
54
+ last_seen_at INTEGER, pinned INTEGER NOT NULL DEFAULT 0,
55
+ access_count INTEGER NOT NULL DEFAULT 0, last_accessed_at INTEGER,
56
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER);
57
+ CREATE INDEX IF NOT EXISTS observations_recent ON observations(project, deleted_at, updated_at DESC);
58
+ CREATE INDEX IF NOT EXISTS observations_topic ON observations(scope, topic_key, project) WHERE topic_key IS NOT NULL;
59
+ CREATE INDEX IF NOT EXISTS observations_hash ON observations(normalized_hash, project);
60
+ CREATE INDEX IF NOT EXISTS observations_session ON observations(session_id, created_at);
61
+ CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(title, content, tool_name, type,
62
+ project, topic_key, content='observations', content_rowid='id', tokenize='trigram');
63
+ CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
64
+ INSERT INTO observations_fts(rowid,title,content,tool_name,type,project,topic_key)
65
+ VALUES(new.id,new.title,new.content,new.tool_name,new.type,new.project,new.topic_key); END;
66
+ CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
67
+ INSERT INTO observations_fts(observations_fts,rowid,title,content,tool_name,type,project,topic_key)
68
+ VALUES('delete',old.id,old.title,old.content,old.tool_name,old.type,old.project,old.topic_key); END;
69
+ CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE OF title,content,tool_name,type,project,topic_key
70
+ ON observations BEGIN
71
+ INSERT INTO observations_fts(observations_fts,rowid,title,content,tool_name,type,project,topic_key)
72
+ VALUES('delete',old.id,old.title,old.content,old.tool_name,old.type,old.project,old.topic_key);
73
+ INSERT INTO observations_fts(rowid,title,content,tool_name,type,project,topic_key)
74
+ VALUES(new.id,new.title,new.content,new.tool_name,new.type,new.project,new.topic_key); END;
75
+ CREATE TABLE IF NOT EXISTS memory_sessions(id TEXT PRIMARY KEY, project TEXT NOT NULL,
76
+ directory TEXT, started_at INTEGER, ended_at INTEGER, summary TEXT);
77
+ CREATE INDEX IF NOT EXISTS memory_sessions_recent ON memory_sessions(project, ended_at DESC);
78
+ CREATE TABLE IF NOT EXISTS user_prompts(id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
79
+ content TEXT NOT NULL, project TEXT NOT NULL, created_at INTEGER NOT NULL);
80
+ CREATE INDEX IF NOT EXISTS user_prompts_recent ON user_prompts(project, created_at DESC);`);
81
+ this.db
82
+ .prepare("INSERT OR IGNORE INTO schema_migrations VALUES(?)")
83
+ .run(MEMORY_SCHEMA_VERSION);
84
+ });
85
+ }
86
+ save(input) {
87
+ const now = this.now();
88
+ const title = cap(redactPrivate(input.title).trim(), 200);
89
+ const content = cap(redactPrivate(input.content).trim(), MAX_OBSERVATION_LENGTH);
90
+ if (!title || !content)
91
+ throw new Error("Memory title and content are required");
92
+ const hash = createHash("sha256").update(normalize(content)).digest("hex");
93
+ const topicKey = input.topicKey ? normalizeTopicKey(input.topicKey) : undefined;
94
+ return this.db.transaction(() => {
95
+ const bump = (id) => {
96
+ this.db
97
+ .prepare("UPDATE observations SET duplicate_count=duplicate_count+1, last_seen_at=?, updated_at=? WHERE id=?")
98
+ .run(now, now, id);
99
+ return { id, action: "duplicate" };
100
+ };
101
+ if (topicKey) {
102
+ // Personal memories upsert across projects; project ones within the project.
103
+ const existing = this.db
104
+ .prepare(`SELECT id, normalized_hash, title FROM observations WHERE deleted_at IS NULL AND scope=? AND topic_key=?
105
+ AND (scope='personal' OR project=?) ORDER BY updated_at DESC LIMIT 1`)
106
+ .get(input.scope, topicKey, input.project);
107
+ if (existing) {
108
+ if (existing.normalized_hash === hash && existing.title === title)
109
+ return bump(existing.id);
110
+ this.db
111
+ .prepare(`UPDATE observations SET title=?, type=?, content=?, normalized_hash=?, session_id=?, tool_name=?,
112
+ revision_count=revision_count+1, last_seen_at=?, updated_at=? WHERE id=?`)
113
+ .run(title, input.type, content, hash, input.session ?? null, input.source ?? null, now, now, existing.id);
114
+ return { id: existing.id, action: "updated" };
115
+ }
116
+ }
117
+ const duplicate = this.db
118
+ .prepare(`SELECT id FROM observations WHERE deleted_at IS NULL AND normalized_hash=? AND project=? AND scope=?
119
+ AND type=? AND title=? AND updated_at>=? ORDER BY updated_at DESC LIMIT 1`)
120
+ .get(hash, input.project, input.scope, input.type, title, now - this.dedupWindowMs);
121
+ if (duplicate)
122
+ return bump(duplicate.id);
123
+ const row = this.db
124
+ .prepare(`INSERT INTO observations(session_id,type,title,content,tool_name,project,scope,topic_key,normalized_hash,
125
+ last_seen_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id`)
126
+ .get(input.session ?? null, input.type, title, content, input.source ?? null, input.project, input.scope, topicKey ?? null, hash, now, now, now);
127
+ return { id: row.id, action: "created" };
128
+ });
129
+ }
130
+ search(project, query, options = {}) {
131
+ const match = ftsQuery(query, options.mode ?? "all");
132
+ if (!match)
133
+ return [];
134
+ const limit = Math.min(Math.max(options.limit ?? 10, 1), 20);
135
+ const where = [
136
+ "observations_fts MATCH ?",
137
+ "o.deleted_at IS NULL",
138
+ ...(options.allProjects ? [] : ["(o.project=? OR o.scope='personal')"]),
139
+ ...(options.type ? ["o.type=?"] : []),
140
+ ...(options.scope ? ["o.scope=?"] : []),
141
+ ];
142
+ const params = [
143
+ match,
144
+ ...(options.allProjects ? [] : [project]),
145
+ ...(options.type ? [options.type] : []),
146
+ ...(options.scope ? [options.scope] : []),
147
+ ];
148
+ const rows = this.db
149
+ .prepare(`SELECT o.*, bm25(observations_fts, 5.0, 1.0, 0.5, 0.5, 0.2, 2.0) AS rank FROM observations_fts
150
+ JOIN observations o ON o.id = observations_fts.rowid WHERE ${where.join(" AND ")} ORDER BY rank LIMIT 100`)
151
+ .all(...params);
152
+ const now = this.now();
153
+ return rows
154
+ .map((r) => ({ r, score: rankScore(r.rank ?? 0, r.updated_at, r.access_count, now) }))
155
+ .sort((a, b) => b.score - a.score)
156
+ .slice(0, limit)
157
+ .map(({ r }) => hit(r));
158
+ }
159
+ recent(project, limit = 10) {
160
+ return this.db
161
+ .prepare(`SELECT * FROM observations o WHERE ${VISIBLE} ORDER BY updated_at DESC, id DESC LIMIT ?`)
162
+ .all(project, Math.min(Math.max(limit, 1), 50)).map(hit);
163
+ }
164
+ pinned(project, limit = 10) {
165
+ return this.db
166
+ .prepare(`SELECT * FROM observations o WHERE ${VISIBLE} AND pinned=1 ORDER BY updated_at DESC LIMIT ?`)
167
+ .all(project, Math.min(Math.max(limit, 1), 50)).map(hit);
168
+ }
169
+ get(id, project) {
170
+ const r = this.db
171
+ .prepare(`SELECT * FROM observations o WHERE id=? AND ${VISIBLE}`)
172
+ .get(id, project);
173
+ if (!r)
174
+ return undefined;
175
+ const now = this.now();
176
+ this.db
177
+ .prepare("UPDATE observations SET access_count=access_count+1, last_accessed_at=? WHERE id=?")
178
+ .run(now, id);
179
+ return {
180
+ id: r.id,
181
+ project: r.project,
182
+ scope: r.scope,
183
+ type: r.type,
184
+ title: r.title,
185
+ content: r.content,
186
+ ...(r.topic_key ? { topicKey: r.topic_key } : {}),
187
+ ...(r.session_id ? { session: r.session_id } : {}),
188
+ createdAt: r.created_at,
189
+ updatedAt: r.updated_at,
190
+ revisionCount: r.revision_count,
191
+ duplicateCount: r.duplicate_count,
192
+ accessCount: r.access_count + 1,
193
+ pinned: !!r.pinned,
194
+ };
195
+ }
196
+ timeline(id, project, before = 3, after = 3) {
197
+ const anchor = this.db
198
+ .prepare(`SELECT * FROM observations o WHERE id=? AND ${VISIBLE}`)
199
+ .get(id, project);
200
+ if (!anchor)
201
+ return [];
202
+ const b = Math.min(Math.max(before, 0), 10), a = Math.min(Math.max(after, 0), 10);
203
+ const scope = anchor.session_id ? "o.session_id=?" : "o.project=?";
204
+ const key = anchor.session_id ?? anchor.project;
205
+ const older = this.db
206
+ .prepare(`SELECT * FROM observations o WHERE ${VISIBLE} AND ${scope} AND (o.created_at<? OR (o.created_at=? AND o.id<?))
207
+ ORDER BY o.created_at DESC, o.id DESC LIMIT ?`)
208
+ .all(project, key, anchor.created_at, anchor.created_at, id, b);
209
+ const newer = this.db
210
+ .prepare(`SELECT * FROM observations o WHERE ${VISIBLE} AND ${scope} AND (o.created_at>? OR (o.created_at=? AND o.id>?))
211
+ ORDER BY o.created_at, o.id LIMIT ?`)
212
+ .all(project, key, anchor.created_at, anchor.created_at, id, a);
213
+ return [...older.reverse(), anchor, ...newer].map(hit);
214
+ }
215
+ pin(id, project, pinned) {
216
+ return (this.db
217
+ .prepare(`UPDATE observations SET pinned=? WHERE id IN (SELECT id FROM observations o WHERE id=? AND ${VISIBLE})`)
218
+ .run(pinned ? 1 : 0, id, project).changes > 0);
219
+ }
220
+ forget(id, project, hard = false) {
221
+ const visible = `id IN (SELECT id FROM observations o WHERE id=? AND ${VISIBLE})`;
222
+ return hard
223
+ ? this.db.prepare(`DELETE FROM observations WHERE ${visible}`).run(id, project).changes > 0
224
+ : this.db
225
+ .prepare(`UPDATE observations SET deleted_at=? WHERE ${visible}`)
226
+ .run(this.now(), id, project).changes > 0;
227
+ }
228
+ count(project) {
229
+ const row = this.db
230
+ .prepare(`SELECT SUM(scope='project' AND project=?) AS p, SUM(scope='personal') AS s FROM observations
231
+ WHERE deleted_at IS NULL`)
232
+ .get(project);
233
+ return { project: row.p ?? 0, personal: row.s ?? 0 };
234
+ }
235
+ recordPrompt(project, session, content) {
236
+ const text = cap(redactPrivate(content).trim(), 2_000);
237
+ if (!text)
238
+ return;
239
+ const now = this.now();
240
+ this.db
241
+ .prepare("INSERT INTO memory_sessions(id,project,started_at) VALUES(?,?,?) ON CONFLICT(id) DO NOTHING")
242
+ .run(session, project, now);
243
+ this.db
244
+ .prepare("INSERT INTO user_prompts(session_id,content,project,created_at) VALUES(?,?,?,?)")
245
+ .run(session, text, project, now);
246
+ }
247
+ recentPrompts(project, limit = 5) {
248
+ return this.db
249
+ .prepare("SELECT session_id, content, created_at FROM user_prompts WHERE project=? ORDER BY created_at DESC, id DESC LIMIT ?")
250
+ .all(project, Math.min(Math.max(limit, 1), 50)).map((r) => ({ session: r.session_id, content: r.content, createdAt: r.created_at }));
251
+ }
252
+ saveSummary(project, session, content) {
253
+ const now = this.now();
254
+ const stored = cap(redactPrivate(content).trim(), MAX_OBSERVATION_LENGTH);
255
+ this.db
256
+ .prepare(`INSERT INTO memory_sessions(id,project,started_at,ended_at,summary) VALUES(?,?,?,?,?)
257
+ ON CONFLICT(id) DO UPDATE SET summary=excluded.summary, ended_at=excluded.ended_at`)
258
+ .run(session, project, now, now, stored);
259
+ return stored;
260
+ }
261
+ lastSummary(project) {
262
+ const row = this.db
263
+ .prepare("SELECT id, project, summary, ended_at FROM memory_sessions WHERE project=? AND summary IS NOT NULL ORDER BY ended_at DESC LIMIT 1")
264
+ .get(project);
265
+ return row
266
+ ? { project: row.project, session: row.id, content: row.summary, createdAt: row.ended_at }
267
+ : undefined;
268
+ }
269
+ /** Session summary of one session, used to confirm an archive write. */
270
+ summaryOf(session) {
271
+ const row = this.db.prepare("SELECT summary FROM memory_sessions WHERE id=?").get(session);
272
+ return row?.summary ?? undefined;
273
+ }
274
+ close() {
275
+ this.db.close();
276
+ }
277
+ }
@@ -0,0 +1,16 @@
1
+ import { type ToolDefinition } from "@alisio/sdk";
2
+ import { type MemoryScope, type MemoryStore } from "./types.ts";
3
+ export interface MemoryToolDeps {
4
+ store: MemoryStore;
5
+ project: string;
6
+ defaultScope: MemoryScope;
7
+ budgetTokens: number;
8
+ /** Called after a successful write so the plugin can refresh its status. */
9
+ onWrite?: (action: string) => void;
10
+ }
11
+ export declare function memoryContextText(deps: MemoryToolDeps, session?: string): string | undefined;
12
+ /**
13
+ * Memory tools use the `internal` effect: they write only to Alisio's memory store, so they
14
+ * remain available under --read-only. Results follow progressive disclosure.
15
+ */
16
+ export declare function memoryTools(deps: MemoryToolDeps): ToolDefinition[];
package/dist/tools.js ADDED
@@ -0,0 +1,165 @@
1
+ import { textResult } from "@alisio/sdk";
2
+ import { formatObservation, renderMemoryContext, suggestTopicKey } from "./format.js";
3
+ import { MEMORY_TYPES } from "./types.js";
4
+ const schema = (properties, required = []) => ({
5
+ type: "object",
6
+ properties,
7
+ required,
8
+ additionalProperties: false,
9
+ });
10
+ const str = (maxLength) => ({ type: "string", minLength: 1, maxLength });
11
+ const id = { type: "integer", minimum: 1 };
12
+ const json = (value) => textResult(JSON.stringify(value));
13
+ export function memoryContextText(deps, session) {
14
+ const { store, project } = deps;
15
+ const summary = store.lastSummary(project);
16
+ const pinned = store.pinned(project, 10);
17
+ const recent = store.recent(project, 20);
18
+ const prompts = store.recentPrompts(project, 5).map((p) => p.content);
19
+ if (!summary && !pinned.length && !recent.length && !prompts.length)
20
+ return undefined;
21
+ return renderMemoryContext({
22
+ project,
23
+ ...(session ? { session } : {}),
24
+ ...(summary ? { summary: summary.content } : {}),
25
+ prompts,
26
+ pinned,
27
+ recent,
28
+ }, deps.budgetTokens);
29
+ }
30
+ /**
31
+ * Memory tools use the `internal` effect: they write only to Alisio's memory store, so they
32
+ * remain available under --read-only. Results follow progressive disclosure.
33
+ */
34
+ export function memoryTools(deps) {
35
+ const { store, project } = deps;
36
+ return [
37
+ {
38
+ name: "memory_save",
39
+ effect: "internal",
40
+ description: "Save a durable observation (decision, bugfix, discovery, pattern, architecture, config, preference, learning). Reuse topic_key (family/description) to update instead of duplicating.",
41
+ inputSchema: schema({
42
+ title: str(200),
43
+ type: { type: "string", enum: [...MEMORY_TYPES] },
44
+ what: str(20_000),
45
+ why: { type: "string", maxLength: 10_000 },
46
+ where: { type: "string", maxLength: 5_000 },
47
+ learned: { type: "string", maxLength: 10_000 },
48
+ topic_key: { type: "string", maxLength: 120 },
49
+ scope: { type: "string", enum: ["project", "personal"] },
50
+ }, ["title", "type", "what"]),
51
+ async execute(input, context) {
52
+ const type = input.type;
53
+ const result = store.save({
54
+ project,
55
+ scope: input.scope ?? deps.defaultScope,
56
+ type,
57
+ title: String(input.title),
58
+ content: formatObservation({
59
+ title: String(input.title),
60
+ type,
61
+ what: String(input.what),
62
+ ...(typeof input.why === "string" ? { why: input.why } : {}),
63
+ ...(typeof input.where === "string" ? { where: input.where } : {}),
64
+ ...(typeof input.learned === "string" ? { learned: input.learned } : {}),
65
+ }),
66
+ ...(typeof input.topic_key === "string" && input.topic_key.trim()
67
+ ? { topicKey: input.topic_key }
68
+ : {}),
69
+ ...(context.session ? { session: context.session } : {}),
70
+ source: "memory_save",
71
+ });
72
+ deps.onWrite?.(result.action);
73
+ return json(input.topic_key
74
+ ? result
75
+ : { ...result, suggested_topic_key: suggestTopicKey(type, String(input.title)) });
76
+ },
77
+ },
78
+ {
79
+ name: "memory_search",
80
+ effect: "internal",
81
+ description: "Search memories (this project plus personal ones). Returns compact rows; use memory_get for full content. Use 1-2 keywords of 3+ characters.",
82
+ inputSchema: schema({
83
+ query: str(500),
84
+ type: { type: "string", enum: [...MEMORY_TYPES] },
85
+ scope: { type: "string", enum: ["project", "personal"] },
86
+ limit: { type: "integer", minimum: 1, maximum: 20 },
87
+ match: { type: "string", enum: ["all", "any"] },
88
+ all_projects: { type: "boolean" },
89
+ }, ["query"]),
90
+ async execute(input) {
91
+ const options = {
92
+ ...(input.type ? { type: input.type } : {}),
93
+ ...(input.scope ? { scope: input.scope } : {}),
94
+ limit: Number(input.limit ?? 8),
95
+ allProjects: input.all_projects === true,
96
+ };
97
+ let mode = input.match ?? "all";
98
+ let results = store.search(project, String(input.query), { ...options, mode });
99
+ if (!results.length && !input.match) {
100
+ mode = "any";
101
+ results = store.search(project, String(input.query), { ...options, mode });
102
+ }
103
+ return json({ mode, results });
104
+ },
105
+ },
106
+ {
107
+ name: "memory_get",
108
+ effect: "internal",
109
+ description: "Get the full content of one memory by id.",
110
+ inputSchema: schema({ id }, ["id"]),
111
+ async execute(input) {
112
+ const record = store.get(Number(input.id), project);
113
+ return record ? json(record) : textResult(`Memory ${String(input.id)} not found`, true);
114
+ },
115
+ },
116
+ {
117
+ name: "memory_context",
118
+ effect: "internal",
119
+ description: "Recent memories, pinned memories, recent prompts and the last session summary for this project (budgeted).",
120
+ inputSchema: schema({}),
121
+ async execute(_input, context) {
122
+ return json({ context: memoryContextText(deps, context.session) ?? "No memories yet." });
123
+ },
124
+ },
125
+ {
126
+ name: "memory_timeline",
127
+ effect: "internal",
128
+ description: "Chronological neighbours of a memory within the same session.",
129
+ inputSchema: schema({
130
+ id,
131
+ before: { type: "integer", minimum: 0, maximum: 10 },
132
+ after: { type: "integer", minimum: 0, maximum: 10 },
133
+ }, ["id"]),
134
+ async execute(input) {
135
+ return json({
136
+ timeline: store.timeline(Number(input.id), project, Number(input.before ?? 3), Number(input.after ?? 3)),
137
+ });
138
+ },
139
+ },
140
+ {
141
+ name: "memory_pin",
142
+ effect: "internal",
143
+ description: "Pin (or unpin) a memory so it is always included in memory context.",
144
+ inputSchema: schema({ id, pinned: { type: "boolean" } }, ["id"]),
145
+ async execute(input) {
146
+ const ok = store.pin(Number(input.id), project, input.pinned !== false);
147
+ if (ok)
148
+ deps.onWrite?.("pinned");
149
+ return json({ pinned: ok && input.pinned !== false, found: ok });
150
+ },
151
+ },
152
+ {
153
+ name: "memory_forget",
154
+ effect: "internal",
155
+ description: "Forget a memory (soft delete; hard=true removes it permanently).",
156
+ inputSchema: schema({ id, hard: { type: "boolean" } }, ["id"]),
157
+ async execute(input) {
158
+ const forgotten = store.forget(Number(input.id), project, input.hard === true);
159
+ if (forgotten)
160
+ deps.onWrite?.("forgotten");
161
+ return json({ forgotten });
162
+ },
163
+ },
164
+ ];
165
+ }
@@ -0,0 +1,83 @@
1
+ /** Memory plugin contract. Core never imports this module. */
2
+ export declare const MEMORY_TYPES: readonly ["decision", "bugfix", "discovery", "pattern", "architecture", "config", "preference", "learning"];
3
+ export type MemoryType = (typeof MEMORY_TYPES)[number];
4
+ /** `project` memories belong to one project id; `personal` ones are visible from every project. */
5
+ export type MemoryScope = "project" | "personal";
6
+ export interface MemoryInput {
7
+ project: string;
8
+ scope: MemoryScope;
9
+ type: MemoryType;
10
+ title: string;
11
+ /** Body, conventionally **What** / **Why** / **Where** / **Learned**. */
12
+ content: string;
13
+ topicKey?: string;
14
+ session?: string;
15
+ /** Producer, e.g. memory_save or compaction (Engram's tool_name). */
16
+ source?: string;
17
+ }
18
+ export interface MemoryRecord extends Omit<MemoryInput, "source"> {
19
+ id: number;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ revisionCount: number;
23
+ duplicateCount: number;
24
+ accessCount: number;
25
+ pinned: boolean;
26
+ }
27
+ /** Compact row: progressive disclosure keeps full bodies behind get(id). */
28
+ export interface MemoryHit {
29
+ id: number;
30
+ type: MemoryType;
31
+ title: string;
32
+ snippet: string;
33
+ scope: MemoryScope;
34
+ topicKey?: string;
35
+ updatedAt: number;
36
+ pinned?: boolean;
37
+ }
38
+ export interface SessionSummary {
39
+ project: string;
40
+ session: string;
41
+ content: string;
42
+ createdAt: number;
43
+ }
44
+ export interface SearchOptions {
45
+ limit?: number;
46
+ type?: MemoryType;
47
+ scope?: MemoryScope;
48
+ /** `all` (default) requires every term; `any` matches any term. */
49
+ mode?: "all" | "any";
50
+ /** Search every project instead of this project plus personal memories. */
51
+ allProjects?: boolean;
52
+ }
53
+ export type SaveAction = "created" | "updated" | "duplicate";
54
+ /** Engram-style local store: no network, no embeddings; soft-deleted rows are always hidden. */
55
+ export interface MemoryStore {
56
+ save(input: MemoryInput): {
57
+ id: number;
58
+ action: SaveAction;
59
+ };
60
+ search(project: string, query: string, options?: SearchOptions): MemoryHit[];
61
+ recent(project: string, limit?: number): MemoryHit[];
62
+ pinned(project: string, limit?: number): MemoryHit[];
63
+ get(id: number, project: string): MemoryRecord | undefined;
64
+ timeline(id: number, project: string, before?: number, after?: number): MemoryHit[];
65
+ pin(id: number, project: string, pinned: boolean): boolean;
66
+ forget(id: number, project: string, hard?: boolean): boolean;
67
+ count(project: string): {
68
+ project: number;
69
+ personal: number;
70
+ };
71
+ recordPrompt(project: string, session: string, content: string): void;
72
+ recentPrompts(project: string, limit?: number): {
73
+ session: string;
74
+ content: string;
75
+ createdAt: number;
76
+ }[];
77
+ /** Stores (redacted, capped) and returns the persisted text. */
78
+ saveSummary(project: string, session: string, content: string): string;
79
+ /** Summary of one session, used to confirm an archive write. */
80
+ summaryOf(session: string): string | undefined;
81
+ lastSummary(project: string): SessionSummary | undefined;
82
+ close(): void;
83
+ }
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Memory plugin contract. Core never imports this module. */
2
+ export const MEMORY_TYPES = [
3
+ "decision",
4
+ "bugfix",
5
+ "discovery",
6
+ "pattern",
7
+ "architecture",
8
+ "config",
9
+ "preference",
10
+ "learning",
11
+ ];
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@alisio/plugin-memory",
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "Engram-style persistent memory for Alisio: SQLite FTS5 observations with topic-key upserts, memory-aware compaction, session summaries and recall tools. Built-in, disableable plugin.",
5
+ "author": "Gustavo Gutiérrez",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/GustavoGutierrez/alisio.git",
10
+ "directory": "packages/plugin-memory"
11
+ },
12
+ "homepage": "https://gustavogutierrez.github.io/alisio/",
13
+ "bugs": {
14
+ "url": "https://github.com/GustavoGutierrez/alisio/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "coding-agent",
19
+ "llm",
20
+ "agent-harness",
21
+ "openai-compatible",
22
+ "alisio-plugin",
23
+ "plugins",
24
+ "memory",
25
+ "fts5",
26
+ "sqlite"
27
+ ],
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "types": "./dist/index.d.ts",
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "sideEffects": false,
42
+ "engines": {
43
+ "node": ">=22.16"
44
+ },
45
+ "dependencies": {
46
+ "zod": "4.6.5"
47
+ },
48
+ "peerDependencies": {
49
+ "@alisio/sdk": "^0.1.0-alpha.2"
50
+ },
51
+ "devDependencies": {
52
+ "@alisio/sdk": "0.1.0-alpha.2"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public",
56
+ "registry": "https://registry.npmjs.com/"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.build.json"
60
+ }
61
+ }