@alisio/plugin-memory 0.1.0-alpha.9 → 0.1.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.js CHANGED
@@ -9,6 +9,7 @@ import { COMPACTION_GUIDANCE, formatObservation, MEMORY_PROTOCOL, memoryLine, OB
9
9
  import { projectId, SQLiteMemoryStore } from "./store.js";
10
10
  import { memoryContextText, memoryTools } from "./tools.js";
11
11
  import { loadVersion } from "./version.js";
12
+ import { memoryViews } from "./views.js";
12
13
  const userTexts = (messages) => messages.flatMap((m) => (m.role === "user" && !m.summary && m.text.trim() ? [m.text] : []));
13
14
  function transcript(messages, maxChars = 60_000) {
14
15
  const lines = messages.map((m) => m.role === "user"
@@ -91,6 +92,9 @@ export function createMemoryPlugin(rawOptions, context) {
91
92
  };
92
93
  for (const tool of memoryTools(deps))
93
94
  api.tools.register(tool);
95
+ // Read-only data views for the web UI. A core without `api.views` simply lacks the tab.
96
+ for (const view of memoryViews({ store: db, project }))
97
+ api.views?.register(view);
94
98
  api.context.register(async () => MEMORY_PROTOCOL);
95
99
  api.compaction.register({
96
100
  async beforeCompact() {
@@ -141,7 +145,17 @@ export function createMemoryPlugin(rawOptions, context) {
141
145
  });
142
146
  api.session.onStart(async (info) => {
143
147
  refresh();
144
- return memoryContextText(deps, info.sessionId);
148
+ const text = memoryContextText(deps, info.sessionId);
149
+ // Remember what this chat was given so the web can show it without parsing the transcript.
150
+ if (text) {
151
+ try {
152
+ db.saveInjectedContext(project, info.sessionId, text);
153
+ }
154
+ catch {
155
+ /* A bookkeeping failure never blocks the context itself. */
156
+ }
157
+ }
158
+ return text;
145
159
  });
146
160
  api.session.onEnd(async (info) => {
147
161
  const prompts = userTexts(info.messages);
package/dist/store.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { SqlDatabase } from "@alisio/sdk";
2
- import type { MemoryHit, MemoryInput, MemoryRecord, MemoryStore, SaveAction, SearchOptions, SessionSummary } from "./types.ts";
2
+ import type { MemoryHit, MemoryInput, MemoryRecord, MemoryStore, SaveAction, SearchOptions, SessionRecord, SessionRecordsQuery, SessionSummary } from "./types.ts";
3
+ /** Largest context text kept per session (the 20 000-token budget at 4 characters per token). */
4
+ export declare const MAX_INJECTED_CONTEXT = 80000;
3
5
  export declare const MAX_OBSERVATION_LENGTH = 50000;
4
6
  /** Stable project id: `<basename>-<sha256(realpath)[0:8]>` of the git root or cwd. */
5
7
  export declare function projectId(path: string): Promise<string>;
@@ -12,7 +14,9 @@ export declare class SQLiteMemoryStore implements MemoryStore {
12
14
  now?: () => number;
13
15
  dedupWindowMs?: number;
14
16
  });
17
+ private applied;
15
18
  private migrate;
19
+ private migrateBase;
16
20
  save(input: MemoryInput): {
17
21
  id: number;
18
22
  action: SaveAction;
@@ -36,6 +40,20 @@ export declare class SQLiteMemoryStore implements MemoryStore {
36
40
  }[];
37
41
  saveSummary(project: string, session: string, content: string): string;
38
42
  lastSummary(project: string): SessionSummary | undefined;
43
+ recordsOfSession(project: string, session: string, query: SessionRecordsQuery): {
44
+ items: SessionRecord[];
45
+ total: number;
46
+ hasMore: boolean;
47
+ };
48
+ summaryRow(project: string, session: string): {
49
+ content: string;
50
+ updatedAt: number;
51
+ } | undefined;
52
+ saveInjectedContext(project: string, session: string, content: string): void;
53
+ injectedContext(project: string, session: string): {
54
+ content: string;
55
+ injectedAt: number;
56
+ } | undefined;
39
57
  /** Session summary of one session, used to confirm an archive write. */
40
58
  summaryOf(session: string): string | undefined;
41
59
  close(): void;
package/dist/store.js CHANGED
@@ -4,6 +4,10 @@ import { basename } from "node:path";
4
4
  import { ftsQuery, normalizeTopicKey, rankScore, redactPrivate } from "./format.js";
5
5
  /** Memory schema versions live at 100+ so the file can be shared with the session store. */
6
6
  const MEMORY_SCHEMA_VERSION = 100;
7
+ /** Adds `injected_context` (what the plugin put in a chat when it started). */
8
+ const INJECTED_CONTEXT_VERSION = 101;
9
+ /** Largest context text kept per session (the 20 000-token budget at 4 characters per token). */
10
+ export const MAX_INJECTED_CONTEXT = 80_000;
7
11
  export const MAX_OBSERVATION_LENGTH = 50_000;
8
12
  const TRUNCATED = "... [truncated]";
9
13
  const SNIPPET = 300;
@@ -41,9 +45,22 @@ export class SQLiteMemoryStore {
41
45
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);");
42
46
  this.migrate();
43
47
  }
48
+ applied(version) {
49
+ return !!this.db.prepare("SELECT 1 FROM schema_migrations WHERE version=?").get(version);
50
+ }
44
51
  migrate() {
45
- if (this.db.prepare("SELECT 1 FROM schema_migrations WHERE version=?").get(MEMORY_SCHEMA_VERSION))
46
- return;
52
+ if (!this.applied(MEMORY_SCHEMA_VERSION))
53
+ this.migrateBase();
54
+ if (!this.applied(INJECTED_CONTEXT_VERSION))
55
+ this.db.transaction(() => {
56
+ this.db.exec(`CREATE TABLE IF NOT EXISTS injected_context(session_id TEXT PRIMARY KEY, project TEXT NOT NULL,
57
+ content TEXT NOT NULL, created_at INTEGER NOT NULL);`);
58
+ this.db
59
+ .prepare("INSERT OR IGNORE INTO schema_migrations VALUES(?)")
60
+ .run(INJECTED_CONTEXT_VERSION);
61
+ });
62
+ }
63
+ migrateBase() {
47
64
  this.db.transaction(() => {
48
65
  this.db.exec(`
49
66
  CREATE TABLE IF NOT EXISTS observations(id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -266,6 +283,88 @@ export class SQLiteMemoryStore {
266
283
  ? { project: row.project, session: row.id, content: row.summary, createdAt: row.ended_at }
267
284
  : undefined;
268
285
  }
286
+ recordsOfSession(project, session, query) {
287
+ const like = (text) => `%${text.replace(/[\\%_]/g, "\\$&")}%`;
288
+ const filters = [
289
+ "o.session_id=?",
290
+ VISIBLE,
291
+ ...(query.type ? ["o.type=?"] : []),
292
+ ...(query.text
293
+ ? [
294
+ "(o.title LIKE ? ESCAPE '\\' OR o.content LIKE ? ESCAPE '\\' OR COALESCE(o.topic_key,'') LIKE ? ESCAPE '\\')",
295
+ ]
296
+ : []),
297
+ ];
298
+ const params = [
299
+ session,
300
+ project,
301
+ ...(query.type ? [query.type] : []),
302
+ ...(query.text ? [like(query.text), like(query.text), like(query.text)] : []),
303
+ ];
304
+ const total = this.db
305
+ .prepare(`SELECT COUNT(*) AS n FROM observations o WHERE ${filters.join(" AND ")}`)
306
+ .get(...params).n;
307
+ const after = query.after;
308
+ const page = after
309
+ ? [
310
+ ...filters,
311
+ "(o.pinned<? OR (o.pinned=? AND o.updated_at<?) OR (o.pinned=? AND o.updated_at=? AND o.id<?))",
312
+ ]
313
+ : filters;
314
+ const pageParams = after
315
+ ? [
316
+ ...params,
317
+ after.pinned,
318
+ after.pinned,
319
+ after.updatedAt,
320
+ after.pinned,
321
+ after.updatedAt,
322
+ after.id,
323
+ ]
324
+ : params;
325
+ const limit = Math.min(Math.max(query.limit, 1), 50);
326
+ const rows = this.db
327
+ .prepare(`SELECT o.* FROM observations o WHERE ${page.join(" AND ")}
328
+ ORDER BY o.pinned DESC, o.updated_at DESC, o.id DESC LIMIT ?`)
329
+ .all(...pageParams, limit + 1);
330
+ return {
331
+ total,
332
+ hasMore: rows.length > limit,
333
+ items: rows.slice(0, limit).map((r) => ({
334
+ id: r.id,
335
+ type: r.type,
336
+ title: r.title,
337
+ content: r.content,
338
+ scope: r.scope,
339
+ ...(r.topic_key ? { topicKey: r.topic_key } : {}),
340
+ ...(r.tool_name ? { source: r.tool_name } : {}),
341
+ pinned: !!r.pinned,
342
+ createdAt: r.created_at,
343
+ updatedAt: r.updated_at,
344
+ revisionCount: r.revision_count,
345
+ duplicateCount: r.duplicate_count,
346
+ })),
347
+ };
348
+ }
349
+ summaryRow(project, session) {
350
+ const row = this.db
351
+ .prepare("SELECT summary, ended_at FROM memory_sessions WHERE id=? AND project=? AND summary IS NOT NULL")
352
+ .get(session, project);
353
+ return row ? { content: row.summary, updatedAt: row.ended_at ?? 0 } : undefined;
354
+ }
355
+ saveInjectedContext(project, session, content) {
356
+ this.db
357
+ .prepare(`INSERT INTO injected_context(session_id,project,content,created_at) VALUES(?,?,?,?)
358
+ ON CONFLICT(session_id) DO UPDATE SET project=excluded.project, content=excluded.content,
359
+ created_at=excluded.created_at`)
360
+ .run(session, project, cap(content, MAX_INJECTED_CONTEXT), this.now());
361
+ }
362
+ injectedContext(project, session) {
363
+ const row = this.db
364
+ .prepare("SELECT content, created_at FROM injected_context WHERE session_id=? AND project=?")
365
+ .get(session, project);
366
+ return row ? { content: row.content, injectedAt: row.created_at } : undefined;
367
+ }
269
368
  /** Session summary of one session, used to confirm an archive write. */
270
369
  summaryOf(session) {
271
370
  const row = this.db.prepare("SELECT summary FROM memory_sessions WHERE id=?").get(session);
package/dist/types.d.ts CHANGED
@@ -41,6 +41,35 @@ export interface SessionSummary {
41
41
  content: string;
42
42
  createdAt: number;
43
43
  }
44
+ /** One row of the "saved in this chat" list (full body, capped by the caller). */
45
+ export interface SessionRecord {
46
+ id: number;
47
+ type: MemoryType;
48
+ title: string;
49
+ content: string;
50
+ scope: MemoryScope;
51
+ topicKey?: string;
52
+ /** Producer, e.g. memory_save or compaction. */
53
+ source?: string;
54
+ pinned: boolean;
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ revisionCount: number;
58
+ duplicateCount: number;
59
+ }
60
+ /** Keyset position in the `pinned DESC, updated_at DESC, id DESC` order. */
61
+ export interface RecordsCursor {
62
+ pinned: 0 | 1;
63
+ updatedAt: number;
64
+ id: number;
65
+ }
66
+ export interface SessionRecordsQuery {
67
+ type?: MemoryType;
68
+ /** Case-insensitive substring of title, content or topic key (literal, not a pattern). */
69
+ text?: string;
70
+ limit: number;
71
+ after?: RecordsCursor;
72
+ }
44
73
  export interface SearchOptions {
45
74
  limit?: number;
46
75
  type?: MemoryType;
@@ -79,5 +108,25 @@ export interface MemoryStore {
79
108
  /** Summary of one session, used to confirm an archive write. */
80
109
  summaryOf(session: string): string | undefined;
81
110
  lastSummary(project: string): SessionSummary | undefined;
111
+ /**
112
+ * Read-only listing for the web "Memory" tab: this chat's records (visible to the project),
113
+ * pinned first then newest, by keyset. Never touches access counters or timestamps.
114
+ */
115
+ recordsOfSession(project: string, session: string, query: SessionRecordsQuery): {
116
+ items: SessionRecord[];
117
+ total: number;
118
+ hasMore: boolean;
119
+ };
120
+ /** Summary stored for one session of this project (read-only). */
121
+ summaryRow(project: string, session: string): {
122
+ content: string;
123
+ updatedAt: number;
124
+ } | undefined;
125
+ /** Remembers the context injected at session start, one row per session. */
126
+ saveInjectedContext(project: string, session: string, content: string): void;
127
+ injectedContext(project: string, session: string): {
128
+ content: string;
129
+ injectedAt: number;
130
+ } | undefined;
82
131
  close(): void;
83
132
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Read-only data views of the memory plugin for the web "Memory" tab. They never write: every
3
+ * query is a plain SELECT (no access counters, no timestamps). Ids and shapes are a contract with
4
+ * the web (`records`, `summary`, `context`); see specs/alisio-web-memory-tab-v1.md.
5
+ */
6
+ import { type ViewDefinition } from "@alisio/sdk";
7
+ import { type MemoryStore, type RecordsCursor } from "./types.ts";
8
+ /** Per-entry content cap in a list (the full body stays available through `/memory show`). */
9
+ export declare const ENTRY_CONTENT_CAP = 10000;
10
+ /** Cap of the summary and context texts shown in the tab. */
11
+ export declare const TEXT_CAP = 50000;
12
+ export interface MemoryViewDeps {
13
+ store: MemoryStore;
14
+ project: string;
15
+ }
16
+ export declare const encodeCursor: (cursor: RecordsCursor) => string;
17
+ export declare function decodeCursor(text: string): RecordsCursor;
18
+ export declare function memoryViews({ store, project }: MemoryViewDeps): ViewDefinition[];
package/dist/views.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Read-only data views of the memory plugin for the web "Memory" tab. They never write: every
3
+ * query is a plain SELECT (no access counters, no timestamps). Ids and shapes are a contract with
4
+ * the web (`records`, `summary`, `context`); see specs/alisio-web-memory-tab-v1.md.
5
+ */
6
+ import { ViewParamsError } from "@alisio/sdk";
7
+ import { MEMORY_TYPES } from "./types.js";
8
+ /** Per-entry content cap in a list (the full body stays available through `/memory show`). */
9
+ export const ENTRY_CONTENT_CAP = 10_000;
10
+ /** Cap of the summary and context texts shown in the tab. */
11
+ export const TEXT_CAP = 50_000;
12
+ const DEFAULT_LIMIT = 20;
13
+ export const encodeCursor = (cursor) => Buffer.from(JSON.stringify([cursor.pinned, cursor.updatedAt, cursor.id])).toString("base64url");
14
+ export function decodeCursor(text) {
15
+ try {
16
+ const value = JSON.parse(Buffer.from(text, "base64url").toString("utf8"));
17
+ if (Array.isArray(value) &&
18
+ value.length === 3 &&
19
+ (value[0] === 0 || value[0] === 1) &&
20
+ Number.isSafeInteger(value[1]) &&
21
+ Number.isSafeInteger(value[2]))
22
+ return { pinned: value[0], updatedAt: value[1], id: value[2] };
23
+ }
24
+ catch {
25
+ /* falls through to the error below */
26
+ }
27
+ throw new ViewParamsError("Invalid cursor");
28
+ }
29
+ const clip = (text, max) => ({
30
+ text: text.length > max ? text.slice(0, max) : text,
31
+ truncated: text.length > max,
32
+ });
33
+ export function memoryViews({ store, project }) {
34
+ return [
35
+ {
36
+ id: "records",
37
+ description: "Memory records saved in the session, pinned first, then newest first",
38
+ params: {
39
+ type: "object",
40
+ properties: {
41
+ type: { type: "string", enum: [...MEMORY_TYPES] },
42
+ q: { type: "string", maxLength: 200 },
43
+ limit: { type: "integer", minimum: 1, maximum: 50, default: DEFAULT_LIMIT },
44
+ cursor: { type: "string", maxLength: 200 },
45
+ },
46
+ },
47
+ handler(params, context) {
48
+ const text = typeof params.q === "string" ? params.q.trim() : "";
49
+ const page = store.recordsOfSession(project, context.sessionId, {
50
+ ...(typeof params.type === "string" ? { type: params.type } : {}),
51
+ ...(text ? { text } : {}),
52
+ limit: typeof params.limit === "number" ? params.limit : DEFAULT_LIMIT,
53
+ ...(typeof params.cursor === "string" && params.cursor
54
+ ? { after: decodeCursor(params.cursor) }
55
+ : {}),
56
+ });
57
+ const last = page.items.at(-1);
58
+ return {
59
+ items: page.items.map(({ content, ...entry }) => {
60
+ const body = clip(content, ENTRY_CONTENT_CAP);
61
+ return { ...entry, content: body.text, contentTruncated: body.truncated };
62
+ }),
63
+ total: page.total,
64
+ ...(page.hasMore && last
65
+ ? {
66
+ next: encodeCursor({
67
+ pinned: last.pinned ? 1 : 0,
68
+ updatedAt: last.updatedAt,
69
+ id: last.id,
70
+ }),
71
+ }
72
+ : {}),
73
+ };
74
+ },
75
+ },
76
+ {
77
+ id: "summary",
78
+ description: "The session summary the memory plugin stored for the session",
79
+ handler(_params, context) {
80
+ const row = store.summaryRow(project, context.sessionId);
81
+ if (!row)
82
+ return { summary: null };
83
+ const body = clip(row.content, TEXT_CAP);
84
+ return {
85
+ summary: { content: body.text, updatedAt: row.updatedAt, truncated: body.truncated },
86
+ };
87
+ },
88
+ },
89
+ {
90
+ id: "context",
91
+ description: "The memory context the plugin injected when the session started",
92
+ handler(_params, context) {
93
+ const row = store.injectedContext(project, context.sessionId);
94
+ if (!row)
95
+ return { context: null };
96
+ const body = clip(row.content, TEXT_CAP);
97
+ return {
98
+ context: { content: body.text, injectedAt: row.injectedAt, truncated: body.truncated },
99
+ };
100
+ },
101
+ },
102
+ ];
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/plugin-memory",
3
- "version": "0.1.0-alpha.9",
3
+ "version": "0.1.0",
4
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
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -46,10 +46,10 @@
46
46
  "zod": "4.6.5"
47
47
  },
48
48
  "peerDependencies": {
49
- "@alisio/sdk": "^0.1.0-alpha.11"
49
+ "@alisio/sdk": "^0.1.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@alisio/sdk": "0.1.0-alpha.11"
52
+ "@alisio/sdk": "0.1.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public",