om-memory-system 4.26.2 → 4.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.
@@ -8,6 +8,11 @@ import type { ImportPathMap } from "./importer.js";
8
8
  * imported set always use the same discovery and project rules.
9
9
  */
10
10
  export declare const MAX_SELECTED_KEYS = 1000;
11
+ /** The outputs a run produces; a session is handled only when every ticked one is finished. */
12
+ export interface ImportOutputs {
13
+ memories: boolean;
14
+ profile: boolean;
15
+ }
11
16
  export interface ImportSessionRow {
12
17
  /**
13
18
  * Selection key: the OpenCode session ID, or the Pi or Claude Code file path
@@ -30,6 +35,10 @@ export interface ImportMatchOptions {
30
35
  /** Grouped imports can retain a valid source with no matching sessions as no-work. */
31
36
  allowEmpty?: boolean;
32
37
  signal?: AbortSignal;
38
+ /** Outputs the job writes; decides which sessions an "all" selection drops as handled. */
39
+ outputs?: ImportOutputs;
40
+ /** A forced run re-analyses handled history, so it keeps handled sessions. */
41
+ force?: boolean;
33
42
  }
34
43
  export declare class StaleSelectionError extends Error {
35
44
  readonly status = 409;
@@ -44,17 +53,27 @@ interface Matched {
44
53
  revision: string;
45
54
  }
46
55
  export declare function matchImportSessions(identity: ImportSourceIdentity, options: ImportMatchOptions, snapshotMode: "fresh" | "reuse" | "any"): Promise<Matched>;
56
+ /**
57
+ * Sessions the ledger shows as finished for every ticked output. The check uses
58
+ * each row's `sessionId`, since Pi and Claude Code keys are file paths. A row
59
+ * without a session id is never handled. The ledger is read, never created.
60
+ */
61
+ export declare function handledKeys(rows: ImportSessionRow[], outputs: ImportOutputs): Promise<Set<string>>;
47
62
  export interface ImportSessionPage {
48
63
  source: {
49
64
  kind: ImportSourceIdentity["kind"];
50
65
  displayPath: string;
51
66
  sourceToken: string;
52
67
  };
68
+ /** Rows shown: handled sessions are left out unless the request keeps them. */
53
69
  total: number;
54
70
  offset: number;
55
71
  rows: Array<ImportSessionRow & {
56
72
  selectable: boolean;
73
+ handled: boolean;
57
74
  }>;
75
+ /** Handled sessions left out of the list. */
76
+ hiddenHandled: number;
58
77
  /** Sessions whose recorded directory no longer exists. */
59
78
  unresolvedCount: number;
60
79
  revision: string;
@@ -65,6 +84,8 @@ export interface SessionListRequest {
65
84
  offset?: number;
66
85
  limit?: number;
67
86
  refresh?: boolean;
87
+ /** True when the page shows handled sessions; "Re-analyse handled history" implies it. */
88
+ includeHandled?: boolean;
68
89
  match: Omit<ImportMatchOptions, "cwd">;
69
90
  }
70
91
  /** Check a session-list request body from the page. */
@@ -75,6 +96,7 @@ export declare function listImportSessions(request: {
75
96
  offset?: number;
76
97
  limit?: number;
77
98
  refresh?: boolean;
99
+ includeHandled?: boolean;
78
100
  }, options: ImportMatchOptions): Promise<ImportSessionPage>;
79
101
  export type ImportSelection = {
80
102
  mode: "ids";
@@ -88,6 +110,8 @@ export type ImportSelection = {
88
110
  excludedKeys: string[];
89
111
  revision: string;
90
112
  listedAt: number;
113
+ /** False or absent: the server drops sessions already handled for the job's outputs. */
114
+ includeHandled?: boolean;
91
115
  };
92
116
  export declare function validateImportSelection(value: unknown): ImportSelection;
93
117
  export interface ResolvedImportSelection {
@@ -10,6 +10,7 @@ import { assertImportSourceUnchanged, importSourceKey, readImportSourceToken, va
10
10
  */
11
11
  export const MAX_SELECTED_KEYS = 1000;
12
12
  const MAX_PAGE = 200;
13
+ const ALL_OUTPUTS = { memories: true, profile: true };
13
14
  export class StaleSelectionError extends Error {
14
15
  status = 409;
15
16
  constructor(message = "The session list is out of date. Refresh the list and try again.") {
@@ -97,6 +98,19 @@ export async function matchImportSessions(identity, options, snapshotMode) {
97
98
  revision: revisionOf(identity, merged, projectTag, matching.map((row) => row.key)),
98
99
  };
99
100
  }
101
+ /**
102
+ * Sessions the ledger shows as finished for every ticked output. The check uses
103
+ * each row's `sessionId`, since Pi and Claude Code keys are file paths. A row
104
+ * without a session id is never handled. The ledger is read, never created.
105
+ */
106
+ export async function handledKeys(rows, outputs) {
107
+ const ids = rows.flatMap((row) => (row.sessionId ? [row.sessionId] : []));
108
+ if (ids.length === 0)
109
+ return new Set();
110
+ const { ImportLedger } = await import("./ledger.js");
111
+ const handled = await new ImportLedger().handledSessionIds(ids, outputs);
112
+ return new Set(rows.filter((row) => row.sessionId && handled.has(row.sessionId)).map((r) => r.key));
113
+ }
100
114
  /** Check a session-list request body from the page. */
101
115
  export function validateSessionListRequest(value) {
102
116
  const input = value;
@@ -124,16 +138,25 @@ export function validateSessionListRequest(value) {
124
138
  throw new Error(`Invalid ${key}`);
125
139
  }
126
140
  }
141
+ for (const key of ["skipMemories", "skipProfile", "force", "includeHandled"]) {
142
+ if (input[key] !== undefined && typeof input[key] !== "boolean") {
143
+ throw new Error(`Invalid ${key}`);
144
+ }
145
+ }
146
+ const force = input.force === true;
127
147
  return {
128
148
  ...(input.source !== undefined ? { sourceToken: input.source } : {}),
129
149
  ...(input.offset !== undefined ? { offset: input.offset } : {}),
130
150
  ...(input.limit !== undefined ? { limit: input.limit } : {}),
131
151
  refresh: input.refresh === true,
152
+ includeHandled: input.includeHandled === true || force,
132
153
  match: {
133
154
  host: input.host,
134
155
  scope,
135
156
  ...(project ? { project } : {}),
136
157
  pathMaps: maps,
158
+ outputs: { memories: input.skipMemories !== true, profile: input.skipProfile !== true },
159
+ ...(force ? { force } : {}),
137
160
  },
138
161
  };
139
162
  }
@@ -152,17 +175,21 @@ export async function listImportSessions(request, options) {
152
175
  // Feed the Directory maps list; a failure here must not break the listing.
153
176
  const { recordUnresolvedDirectories, summarizeUnresolvedDirectories } = await import("../services/backfill-state.js");
154
177
  await recordUnresolvedDirectories(options.host, summarizeUnresolvedDirectories(matched.unresolved.map((row) => ({ directory: row.recordedDirectory })))).catch(() => { });
155
- const rows = [
156
- ...matched.matching.map((row) => ({ ...row, selectable: true })),
178
+ const handled = await handledKeys(matched.matching, options.outputs ?? ALL_OUTPUTS);
179
+ const listed = [
180
+ ...matched.matching.map((row) => ({ ...row, selectable: true, handled: handled.has(row.key) })),
157
181
  ...(options.scope === "all-projects"
158
- ? matched.unresolved.map((row) => ({ ...row, selectable: false }))
182
+ ? matched.unresolved.map((row) => ({ ...row, selectable: false, handled: false }))
159
183
  : []),
160
184
  ];
185
+ const keep = request.includeHandled === true || options.force === true;
186
+ const rows = keep ? listed : listed.filter((row) => !row.handled);
161
187
  return {
162
188
  source: { kind: identity.kind, displayPath: identity.realPath, sourceToken: token },
163
189
  total: rows.length,
164
190
  offset,
165
191
  rows: rows.slice(offset, offset + limit),
192
+ hiddenHandled: listed.length - rows.length,
166
193
  unresolvedCount: matched.unresolved.length,
167
194
  revision: matched.revision,
168
195
  listedAt,
@@ -201,7 +228,16 @@ export function validateImportSelection(value) {
201
228
  }
202
229
  if (typeof input.revision !== "string")
203
230
  throw new Error("Invalid session list revision");
204
- return { mode: "all", excludedKeys: excluded, revision: input.revision, listedAt };
231
+ if (input.includeHandled !== undefined && typeof input.includeHandled !== "boolean") {
232
+ throw new Error("Invalid includeHandled");
233
+ }
234
+ return {
235
+ mode: "all",
236
+ excludedKeys: excluded,
237
+ revision: input.revision,
238
+ listedAt,
239
+ ...(input.includeHandled === true ? { includeHandled: true } : {}),
240
+ };
205
241
  }
206
242
  throw new Error("Choose sessions to import");
207
243
  }
@@ -223,7 +259,13 @@ export async function resolveImportSelection(sourceToken, selection, options) {
223
259
  if (matched.revision !== selection.revision)
224
260
  throw new StaleSelectionError();
225
261
  const excluded = new Set(selection.excludedKeys);
226
- keys = matched.matching.map((row) => row.key).filter((key) => !excluded.has(key));
262
+ const keep = selection.includeHandled === true || options.force === true;
263
+ const handled = keep
264
+ ? new Set()
265
+ : await handledKeys(matched.matching, options.outputs ?? ALL_OUTPUTS);
266
+ keys = matched.matching
267
+ .map((row) => row.key)
268
+ .filter((key) => !excluded.has(key) && !handled.has(key));
227
269
  }
228
270
  else {
229
271
  const current = new Map(matched.matching.map((row) => [row.key, row.directory]));
@@ -32,6 +32,17 @@ export declare class ImportLedger {
32
32
  * A ledger file without the table reads as empty.
33
33
  */
34
34
  peek(key: string): Promise<ImportLedgerRow | null>;
35
+ /**
36
+ * Sessions whose ledger rows are all finished for every ticked output.
37
+ * Memory rows and profile rows (`project_hash = 'profile'`) are judged apart.
38
+ * A ticked output passes when the session has at least one row of its kind
39
+ * and every such row is imported or skipped. Read-only like `peek`: a missing
40
+ * file or table gives an empty set and nothing is created.
41
+ */
42
+ handledSessionIds(sessionIds: readonly string[], outputs: {
43
+ memories: boolean;
44
+ profile: boolean;
45
+ }): Promise<Set<string>>;
35
46
  countByStatus(): Promise<Record<string, number>>;
36
47
  /** Upsert an in-progress marker before processing a unit. */
37
48
  begin(input: {
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import { CONFIG } from "../config.js";
3
4
  import { tursoConnectionManager } from "../services/turso/connection-manager.js";
@@ -87,6 +88,59 @@ export class ImportLedger {
87
88
  const row = await db.get(`SELECT * FROM import_ledger WHERE key = ?`, [key]);
88
89
  return row ? rowToEntry(row) : null;
89
90
  }
91
+ /**
92
+ * Sessions whose ledger rows are all finished for every ticked output.
93
+ * Memory rows and profile rows (`project_hash = 'profile'`) are judged apart.
94
+ * A ticked output passes when the session has at least one row of its kind
95
+ * and every such row is imported or skipped. Read-only like `peek`: a missing
96
+ * file or table gives an empty set and nothing is created.
97
+ */
98
+ async handledSessionIds(sessionIds, outputs) {
99
+ const handled = new Set();
100
+ const ids = [...new Set(sessionIds.filter((id) => id !== ""))];
101
+ if ((!outputs.memories && !outputs.profile) || ids.length === 0)
102
+ return handled;
103
+ // Opening a connection creates the file, so a missing ledger is read as empty first.
104
+ if (!existsSync(importLedgerDbPath()))
105
+ return handled;
106
+ const db = await tursoConnectionManager.getConnection(importLedgerDbPath());
107
+ const table = await db.get(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'import_ledger'`);
108
+ if (!table)
109
+ return handled;
110
+ const kinds = new Map();
111
+ for (let start = 0; start < ids.length; start += 500) {
112
+ const chunk = ids.slice(start, start + 500);
113
+ const rows = await db.all(`
114
+ SELECT session_id,
115
+ CASE WHEN project_hash = 'profile' THEN 'profile' ELSE 'memory' END AS kind,
116
+ COUNT(*) AS total,
117
+ SUM(CASE WHEN status IN ('imported', 'skipped') THEN 1 ELSE 0 END) AS done
118
+ FROM import_ledger
119
+ WHERE session_id IN (${chunk.map(() => "?").join(", ")})
120
+ GROUP BY session_id, kind
121
+ `, chunk);
122
+ for (const row of rows) {
123
+ const entry = kinds.get(String(row.session_id)) ?? {
124
+ memory: [0, 0],
125
+ profile: [0, 0],
126
+ };
127
+ entry[row.kind === "profile" ? "profile" : "memory"] = [
128
+ Number(row.total),
129
+ Number(row.done),
130
+ ];
131
+ kinds.set(String(row.session_id), entry);
132
+ }
133
+ }
134
+ const passes = ([total, done]) => total > 0 && total === done;
135
+ for (const [sessionId, entry] of kinds) {
136
+ if (outputs.memories && !passes(entry.memory))
137
+ continue;
138
+ if (outputs.profile && !passes(entry.profile))
139
+ continue;
140
+ handled.add(sessionId);
141
+ }
142
+ return handled;
143
+ }
90
144
  async countByStatus() {
91
145
  const db = await this.db();
92
146
  const rows = await db.all(`SELECT status, COUNT(*) AS count FROM import_ledger GROUP BY status`);
@@ -52,6 +52,12 @@ type Deps = GroupImportDeps & {
52
52
  resolveSelection: typeof resolveImportSelection;
53
53
  readiness: () => Promise<ImportReadiness>;
54
54
  };
55
+ /**
56
+ * Fixed sentences with no number, so the page can translate them by exact text.
57
+ * The failed count travels apart in the child's summary.
58
+ */
59
+ export declare const MEMORY_FAILED_MESSAGE = "Some memory units failed. Completed work is saved. A new preview and import retries only the failed units.";
60
+ export declare const PROFILE_FAILED_MESSAGE = "Profile learning failed. Completed memory work is saved. Refresh the preview and retry.";
55
61
  /** Preflight every child before launching a bounded sequential group in the existing slot. */
56
62
  export declare function startImportGroup(input: GroupImportRequest, directory: string, deps: Deps, publish: (job: GroupImportJob, controller: AbortController) => void): Promise<GroupImportJob>;
57
63
  export {};
@@ -38,6 +38,12 @@ async function discardSelection(identity) {
38
38
  const { opencodeSnapshots } = await import("./opencode-snapshot.js");
39
39
  opencodeSnapshots.discard(importSourceKey(identity));
40
40
  }
41
+ /**
42
+ * Fixed sentences with no number, so the page can translate them by exact text.
43
+ * The failed count travels apart in the child's summary.
44
+ */
45
+ export const MEMORY_FAILED_MESSAGE = "Some memory units failed. Completed work is saved. A new preview and import retries only the failed units.";
46
+ export const PROFILE_FAILED_MESSAGE = "Profile learning failed. Completed memory work is saved. Refresh the preview and retry.";
41
47
  function matchOptions(child, directory, signal) {
42
48
  return {
43
49
  host: child.input.host,
@@ -47,6 +53,8 @@ function matchOptions(child, directory, signal) {
47
53
  cwd: directory,
48
54
  allowEmpty: true,
49
55
  signal,
56
+ outputs: { memories: !child.args.skipMemories, profile: !child.args.skipProfile },
57
+ ...(child.args.force ? { force: true } : {}),
50
58
  };
51
59
  }
52
60
  function hostError(host, error) {
@@ -326,7 +334,7 @@ async function executeGroup(children, job, signal, directory, deps, waiting) {
326
334
  else if (historyImportFailed(report)) {
327
335
  child.row.state = "failed";
328
336
  failed = true;
329
- child.row.error = `${hostLabel(host)}: ${report.unitsFailed ? "Memory work failed" : "Profile learning failed"}. Refresh the preview and retry.`;
337
+ child.row.error = `${hostLabel(host)}: ${report.unitsFailed ? MEMORY_FAILED_MESSAGE : PROFILE_FAILED_MESSAGE}`;
330
338
  job.error = child.row.error;
331
339
  }
332
340
  else {