om-memory-system 3.2.0-next.9 → 3.3.0-next.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/adapters/opencode/auto-capture-summary.js +50 -8
  2. package/dist/adapters/opencode/import-command.d.ts +3 -7
  3. package/dist/adapters/opencode/import-command.js +3 -54
  4. package/dist/adapters/pi/extension.js +3 -0
  5. package/dist/adapters/pi/profile.js +2 -1
  6. package/dist/adapters/pi/provider.js +41 -14
  7. package/dist/config.d.ts +18 -1
  8. package/dist/config.js +103 -9
  9. package/dist/core/capture-context.js +1 -1
  10. package/dist/core/capture.d.ts +4 -0
  11. package/dist/core/capture.js +30 -0
  12. package/dist/core/extraction.d.ts +23 -1
  13. package/dist/core/extraction.js +41 -0
  14. package/dist/core/host.d.ts +23 -0
  15. package/dist/core/host.js +9 -1
  16. package/dist/core/profile-analysis.js +9 -1
  17. package/dist/importer/discovery.d.ts +32 -5
  18. package/dist/importer/discovery.js +88 -36
  19. package/dist/importer/import-args.d.ts +23 -1
  20. package/dist/importer/import-args.js +29 -1
  21. package/dist/importer/import-project.d.ts +16 -0
  22. package/dist/importer/import-project.js +35 -0
  23. package/dist/importer/import-readiness.d.ts +47 -0
  24. package/dist/importer/import-readiness.js +54 -0
  25. package/dist/importer/import-sessions.d.ts +98 -0
  26. package/dist/importer/import-sessions.js +222 -0
  27. package/dist/importer/import-sources.d.ts +47 -0
  28. package/dist/importer/import-sources.js +147 -0
  29. package/dist/importer/importer.d.ts +31 -1
  30. package/dist/importer/importer.js +82 -24
  31. package/dist/importer/opencode-import.d.ts +11 -1
  32. package/dist/importer/opencode-import.js +133 -69
  33. package/dist/importer/opencode-project.d.ts +1 -4
  34. package/dist/importer/opencode-project.js +3 -36
  35. package/dist/importer/opencode-reader.d.ts +25 -5
  36. package/dist/importer/opencode-reader.js +92 -106
  37. package/dist/importer/opencode-snapshot.d.ts +82 -0
  38. package/dist/importer/opencode-snapshot.js +276 -0
  39. package/dist/importer/profile-import.d.ts +1 -0
  40. package/dist/importer/profile-import.js +7 -1
  41. package/dist/importer/run-import.d.ts +10 -0
  42. package/dist/importer/run-import.js +21 -0
  43. package/dist/importer/settings-health.d.ts +25 -0
  44. package/dist/importer/settings-health.js +136 -0
  45. package/dist/importer/web-import-api.d.ts +8 -0
  46. package/dist/importer/web-import-api.js +8 -0
  47. package/dist/importer/web-import-jobs.d.ts +53 -0
  48. package/dist/importer/web-import-jobs.js +227 -0
  49. package/dist/index.js +4 -0
  50. package/dist/services/ai/live-model-choice.js +6 -3
  51. package/dist/services/ai/opencode-import-models.d.ts +11 -0
  52. package/dist/services/ai/opencode-import-models.js +55 -0
  53. package/dist/services/ai/opencode-provider.d.ts +9 -0
  54. package/dist/services/ai/opencode-provider.js +31 -4
  55. package/dist/services/ai/providers/anthropic-messages.js +14 -6
  56. package/dist/services/ai/providers/base-provider.d.ts +8 -0
  57. package/dist/services/ai/providers/base-provider.js +12 -0
  58. package/dist/services/ai/providers/google-gemini.js +21 -7
  59. package/dist/services/ai/providers/openai-chat-completion.js +20 -7
  60. package/dist/services/ai/providers/openai-responses.js +11 -0
  61. package/dist/services/capture-attempt-store.d.ts +21 -0
  62. package/dist/services/capture-attempt-store.js +108 -0
  63. package/dist/services/capture-diagnostics.d.ts +66 -0
  64. package/dist/services/capture-diagnostics.js +175 -0
  65. package/dist/services/cleanup-service.js +9 -0
  66. package/dist/services/global-config-writer.d.ts +10 -0
  67. package/dist/services/global-config-writer.js +106 -0
  68. package/dist/services/log-path.d.ts +2 -0
  69. package/dist/services/log-path.js +15 -0
  70. package/dist/services/logger.js +1 -12
  71. package/dist/services/settings-log.d.ts +5 -0
  72. package/dist/services/settings-log.js +36 -0
  73. package/dist/services/settings-models.d.ts +35 -0
  74. package/dist/services/settings-models.js +39 -0
  75. package/dist/services/settings-snapshot.d.ts +24 -0
  76. package/dist/services/settings-snapshot.js +71 -0
  77. package/dist/services/settings-traces.d.ts +7 -0
  78. package/dist/services/settings-traces.js +39 -0
  79. package/dist/services/user-memory-learning.js +2 -1
  80. package/dist/services/web-server.d.ts +5 -0
  81. package/dist/services/web-server.js +176 -3
  82. package/dist/v2/legacy-client.js +19 -2
  83. package/dist/web/assets/index-BMkPht7g.js +124 -0
  84. package/dist/web/assets/index-D5ak5KsI.css +2 -0
  85. package/dist/web/index.html +2 -2
  86. package/package.json +2 -1
  87. package/dist/web/assets/index-DPjEpD1h.css +0 -2
  88. package/dist/web/assets/index-do26c4JJ.js +0 -122
@@ -1,3 +1,5 @@
1
+ import { CONFIG, refreshConfigIfChanged } from "../config.js";
2
+ import { buildCaptureAttemptRecord, emitCaptureAttempt } from "../services/capture-diagnostics.js";
1
3
  import { memoryClient } from "../services/client.js";
2
4
  import { getTags } from "../services/tags.js";
3
5
  import { buildMarkdownContext, getAutoCaptureMarkdownBudget } from "./capture-context.js";
@@ -15,7 +17,30 @@ async function getLatestProjectMemory(containerTag) {
15
17
  return null;
16
18
  }
17
19
  }
20
+ /**
21
+ * Run one capture attempt and write exactly one diagnostics record for it,
22
+ * whether it is saved, skipped, or fails.
23
+ */
18
24
  export async function captureConversation(workUnit, provider) {
25
+ refreshConfigIfChanged(workUnit.projectDirectory);
26
+ const diagnostics = {};
27
+ const startedAt = Date.now();
28
+ let outcome = "failed";
29
+ try {
30
+ const result = await runCapture(workUnit, provider, diagnostics);
31
+ outcome = result.status === "captured" ? "saved" : "skipped";
32
+ return result;
33
+ }
34
+ finally {
35
+ const record = buildCaptureAttemptRecord({
36
+ host: workUnit.host,
37
+ sourceType: workUnit.sourceType,
38
+ sessionId: workUnit.hostSessionId,
39
+ }, diagnostics, outcome, Date.now() - startedAt);
40
+ emitCaptureAttempt(record, diagnostics, CONFIG);
41
+ }
42
+ }
43
+ async function runCapture(workUnit, provider, diagnostics) {
19
44
  const tags = getTags(workUnit.projectDirectory);
20
45
  const latestMemory = await getLatestProjectMemory(tags.project.tag);
21
46
  const context = buildMarkdownContext(workUnit.userPrompt, workUnit.textResponses, workUnit.toolCalls, latestMemory, getAutoCaptureMarkdownBudget());
@@ -27,9 +52,11 @@ export async function captureConversation(workUnit, provider) {
27
52
  projectDirectory: workUnit.projectDirectory,
28
53
  userPrompt: workUnit.userPrompt,
29
54
  prompt: workUnit.prompt,
55
+ diagnostics,
30
56
  });
31
57
  }
32
58
  catch (error) {
59
+ diagnostics.failureReason ??= "call-error";
33
60
  const message = error instanceof Error ? error.message : String(error);
34
61
  throw new Error(`Summary generation failed: ${message}`, { cause: error });
35
62
  }
@@ -40,6 +67,8 @@ export async function captureConversation(workUnit, provider) {
40
67
  ? `${summaryResult.summary}\n\nTags: ${summaryResult.tags.join(", ")}`
41
68
  : summaryResult.summary;
42
69
  const source = workUnit.sourceType === "history-import" ? "import" : "auto-capture";
70
+ // Anything that goes wrong from here on is a storage failure, thrown or reported.
71
+ diagnostics.failureReason = "persist-error";
43
72
  const result = await memoryClient.addMemory(summaryWithTags, tags.project.tag, {
44
73
  source,
45
74
  type: summaryResult.type,
@@ -64,5 +93,6 @@ export async function captureConversation(workUnit, provider) {
64
93
  if (!result.success) {
65
94
  throw new Error(`Memory persistence failed: ${result.error || "database write failed"}`);
66
95
  }
96
+ diagnostics.failureReason = undefined;
67
97
  return { status: "captured", memoryId: result.id };
68
98
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { CaptureSummary } from "./host.js";
2
+ import type { CaptureFailureReason, CaptureSummary } from "./host.js";
3
3
  /**
4
4
  * Shared structured-extraction contract for automatic capture.
5
5
  *
@@ -38,6 +38,13 @@ export declare const captureSummaryToolSchema: {
38
38
  };
39
39
  required: string[];
40
40
  };
41
+ /**
42
+ * Reply-format instruction for paths that get plain text back (the Pi model
43
+ * bridge). Structured-output and tool-call paths enforce the shape themselves,
44
+ * so without this the model only sees the Markdown layout of the summary field
45
+ * and `type="skip"`, and tends to answer in that form instead of JSON.
46
+ */
47
+ export declare function buildCaptureReplyInstruction(): string;
41
48
  export declare function buildCaptureSystemPrompt(languageName: string): string;
42
49
  /** Upper bound for LLM-inferred preference confidence (0–1 scale). */
43
50
  export declare const USER_PROFILE_LLM_CONFIDENCE_MAX = 1;
@@ -78,3 +85,18 @@ export declare function extractJsonObject(raw: string): unknown;
78
85
  * blocks and surrounding prose. Returns null when no valid payload is present.
79
86
  */
80
87
  export declare function parseCaptureSummary(raw: string): CaptureSummary | null;
88
+ /**
89
+ * Map provider-specific stop reasons onto one vocabulary so a length limit
90
+ * reads as `length` on every extraction path. Other values pass through in
91
+ * lower case.
92
+ */
93
+ export declare function normalizeStopReason(stopReason: string | null | undefined): string | undefined;
94
+ /**
95
+ * Explain why a reply is not a usable capture summary. Returns null when the
96
+ * reply parses (including a skip). Codes are checked in the spec's order, so a
97
+ * cut-off reply reads as `truncated` rather than `invalid-json`.
98
+ */
99
+ export declare function classifyCaptureReply(reply: {
100
+ text: string;
101
+ stopReason?: string | null;
102
+ }): CaptureFailureReason | null;
@@ -40,6 +40,18 @@ export const captureSummaryToolSchema = {
40
40
  },
41
41
  required: ["summary", "type", "tags"],
42
42
  };
43
+ /**
44
+ * Reply-format instruction for paths that get plain text back (the Pi model
45
+ * bridge). Structured-output and tool-call paths enforce the shape themselves,
46
+ * so without this the model only sees the Markdown layout of the summary field
47
+ * and `type="skip"`, and tends to answer in that form instead of JSON.
48
+ */
49
+ export function buildCaptureReplyInstruction() {
50
+ return `Reply with only one JSON object and nothing else: no prose, no code fence. It must match this JSON schema:
51
+ ${JSON.stringify(captureSummaryToolSchema)}
52
+ Put the Markdown summary (## Request / ## Outcome) inside the "summary" string.
53
+ For a non-technical conversation reply exactly: {"type":"skip","summary":"","tags":[]}`;
54
+ }
43
55
  export function buildCaptureSystemPrompt(languageName) {
44
56
  return `You are a technical memory recorder for a software development project.
45
57
 
@@ -143,3 +155,32 @@ export function parseCaptureSummary(raw) {
143
155
  tags: parsed.data.tags.map((tag) => tag.toLowerCase().trim()).filter(Boolean),
144
156
  };
145
157
  }
158
+ const LENGTH_STOP_REASONS = new Set(["length", "max_tokens", "max_output_tokens"]);
159
+ /**
160
+ * Map provider-specific stop reasons onto one vocabulary so a length limit
161
+ * reads as `length` on every extraction path. Other values pass through in
162
+ * lower case.
163
+ */
164
+ export function normalizeStopReason(stopReason) {
165
+ if (!stopReason)
166
+ return undefined;
167
+ const lower = stopReason.toLowerCase();
168
+ return LENGTH_STOP_REASONS.has(lower) ? "length" : lower;
169
+ }
170
+ /**
171
+ * Explain why a reply is not a usable capture summary. Returns null when the
172
+ * reply parses (including a skip). Codes are checked in the spec's order, so a
173
+ * cut-off reply reads as `truncated` rather than `invalid-json`.
174
+ */
175
+ export function classifyCaptureReply(reply) {
176
+ if (parseCaptureSummary(reply.text))
177
+ return null;
178
+ if (reply.text.trim().length === 0)
179
+ return "empty-text";
180
+ if (normalizeStopReason(reply.stopReason) === "length")
181
+ return "truncated";
182
+ const json = extractJsonObject(reply.text);
183
+ if (json === null || typeof json !== "object" || Array.isArray(json))
184
+ return "invalid-json";
185
+ return "schema-mismatch";
186
+ }
@@ -19,12 +19,35 @@ export interface CaptureSummary {
19
19
  type: string;
20
20
  tags: string[];
21
21
  }
22
+ export type CaptureAttemptOutcome = "saved" | "skipped" | "failed";
23
+ /** Fixed failure codes, in the order classification checks them. */
24
+ export declare const CAPTURE_FAILURE_REASONS: readonly ["call-error", "empty-text", "truncated", "invalid-json", "schema-mismatch", "persist-error"];
25
+ export type CaptureFailureReason = (typeof CAPTURE_FAILURE_REASONS)[number];
26
+ export type CaptureExtractionPath = "host-model" | "external-api";
27
+ /**
28
+ * Filled in by the extraction path during one capture attempt. The capture
29
+ * pipeline owns the object and emits it once the outcome is known. Fields a
30
+ * path cannot observe stay undefined. The prompt and reply fields are only
31
+ * ever written to the opt-in trace file, never to the log.
32
+ */
33
+ export interface CaptureAttemptDiagnostics {
34
+ path?: CaptureExtractionPath;
35
+ provider?: string;
36
+ model?: string;
37
+ stopReason?: string;
38
+ blockTypes?: string[];
39
+ systemPrompt?: string;
40
+ userPrompt?: string;
41
+ rawReply?: string;
42
+ failureReason?: CaptureFailureReason;
43
+ }
22
44
  export interface CaptureSummaryRequest {
23
45
  context: string;
24
46
  sessionId: string;
25
47
  projectDirectory: string;
26
48
  userPrompt: string;
27
49
  prompt?: CapturePromptContext;
50
+ diagnostics?: CaptureAttemptDiagnostics;
28
51
  }
29
52
  export interface CaptureSummaryProvider {
30
53
  summarize(request: CaptureSummaryRequest): Promise<CaptureSummary | null>;
package/dist/core/host.js CHANGED
@@ -1 +1,9 @@
1
- export {};
1
+ /** Fixed failure codes, in the order classification checks them. */
2
+ export const CAPTURE_FAILURE_REASONS = [
3
+ "call-error",
4
+ "empty-text",
5
+ "truncated",
6
+ "invalid-json",
7
+ "schema-mismatch",
8
+ "persist-error",
9
+ ];
@@ -10,7 +10,15 @@ CRITICAL: Detect the language used by the user in their prompts. You MUST output
10
10
 
11
11
  CRITICAL: All JSON string values MUST escape double quotes with backslash. Do NOT use unescaped quotation marks inside string values.
12
12
 
13
- Respond with a single JSON object matching the update_user_profile contract.`;
13
+ Respond with one JSON object. It must have these required fields:
14
+ {
15
+ "preferences": [{ "category": "string", "description": "string", "confidence": 0.8, "evidence": ["string"] }],
16
+ "patterns": [{ "category": "string", "description": "string" }],
17
+ "workflows": [{ "description": "string", "steps": ["string"] }]
18
+ }
19
+ Use an empty array for a field with no findings. Confidence must be a number from 0 to 1.
20
+ If you include "validations", each entry needs a numeric "index", a string "reason", and a "verdict" of "confirmed", "contradicted", "no_evidence", "inaccurate", or "oversimplified".
21
+ Return JSON only, without Markdown or other text.`;
14
22
  }
15
23
  /** Analyse prompts with a host-neutral model and merge the validated profile. */
16
24
  export async function analyzeProfile(model, context, existingProfile, timeoutMs = 120000) {
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * Discovery of Pi session files for the historical importer. Read-only: only
3
- * the first line (the session header) is parsed here, so discovery stays cheap
4
- * and full parsing happens later per candidate session.
3
+ * the first line (the session header) is parsed here, from at most the first
4
+ * 64 KB of each file, so discovery stays cheap on large histories and full
5
+ * parsing happens later per candidate session.
5
6
  *
6
- * Files that are not Pi session format (for example subagent artifacts, which
7
- * use a different record format without a `type: "session"` header) are
8
- * reported as unrecognized and skipped rather than treated as errors.
7
+ * The root may be a sessions folder or one `.jsonl` session file. Files that
8
+ * are not Pi session format (for example subagent artifacts, which use a
9
+ * different record format without a `type: "session"` header) are reported
10
+ * as unrecognized and skipped rather than treated as errors. Symlinked entries
11
+ * inside a folder are never followed.
9
12
  */
10
13
  export declare const DEFAULT_PI_SESSION_ROOT: string;
14
+ export declare const PI_HEADER_READ_LIMIT: number;
11
15
  export interface DiscoveredPiSession {
12
16
  file: string;
17
+ /** Path relative to the root, with `/` separators; the file name for a file root. */
18
+ key: string;
13
19
  sessionId: string | null;
14
20
  cwd: string | null;
15
21
  version: number | null;
@@ -24,8 +30,29 @@ export interface DiscoveryResult {
24
30
  sessions: DiscoveredPiSession[];
25
31
  unrecognized: UnrecognizedFile[];
26
32
  }
33
+ /**
34
+ * Bounds on a folder walk. A Pi sessions folder holds a few hundred folders,
35
+ * so a walk past the folder or file limit is almost certainly a home folder,
36
+ * `/`, or a whole backup volume, and would block the process for minutes.
37
+ * Depth is only a backstop: subagent runs nest session folders seven or more
38
+ * levels deep (`<project>/<run>/<id>/run-0/session/<id>/run-0`).
39
+ */
40
+ export interface DiscoveryLimits {
41
+ maxDepth: number;
42
+ maxFolders: number;
43
+ maxFiles: number;
44
+ }
45
+ export declare const DEFAULT_DISCOVERY_LIMITS: DiscoveryLimits;
46
+ export declare class DiscoveryLimitError extends Error {
47
+ constructor();
48
+ }
27
49
  export interface DiscoveryOptions {
28
50
  root?: string;
29
51
  maxSessions?: number;
52
+ limits?: DiscoveryLimits;
30
53
  }
54
+ /** Read the first line of a file without reading past `PI_HEADER_READ_LIMIT` bytes. */
55
+ export declare function readFirstLine(file: string, limit?: number): string;
56
+ /** Parse one file's header, or say why the file is not a Pi session. */
57
+ export declare function readPiSessionHeader(file: string, key: string): DiscoveredPiSession | UnrecognizedFile;
31
58
  export declare function discoverPiSessions(options?: DiscoveryOptions): DiscoveryResult;
@@ -1,22 +1,39 @@
1
- import { readdirSync, readFileSync } from "node:fs";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
1
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, join, relative, sep } from "node:path";
4
3
  import { homedir } from "node:os";
5
4
  /**
6
5
  * Discovery of Pi session files for the historical importer. Read-only: only
7
- * the first line (the session header) is parsed here, so discovery stays cheap
8
- * and full parsing happens later per candidate session.
6
+ * the first line (the session header) is parsed here, from at most the first
7
+ * 64 KB of each file, so discovery stays cheap on large histories and full
8
+ * parsing happens later per candidate session.
9
9
  *
10
- * Files that are not Pi session format (for example subagent artifacts, which
11
- * use a different record format without a `type: "session"` header) are
12
- * reported as unrecognized and skipped rather than treated as errors.
10
+ * The root may be a sessions folder or one `.jsonl` session file. Files that
11
+ * are not Pi session format (for example subagent artifacts, which use a
12
+ * different record format without a `type: "session"` header) are reported
13
+ * as unrecognized and skipped rather than treated as errors. Symlinked entries
14
+ * inside a folder are never followed.
13
15
  */
14
16
  export const DEFAULT_PI_SESSION_ROOT = join(homedir(), ".pi", "agent", "sessions");
15
- function listJsonlFiles(root) {
17
+ export const PI_HEADER_READ_LIMIT = 64 * 1024;
18
+ export const DEFAULT_DISCOVERY_LIMITS = {
19
+ maxDepth: 32,
20
+ maxFolders: 5_000,
21
+ maxFiles: 20_000,
22
+ };
23
+ export class DiscoveryLimitError extends Error {
24
+ constructor() {
25
+ super("This folder is too large to scan for Pi sessions. Choose the Pi sessions folder itself, or one .jsonl file.");
26
+ this.name = "DiscoveryLimitError";
27
+ }
28
+ }
29
+ function listJsonlFiles(root, limits) {
16
30
  const files = [];
17
- const stack = [root];
31
+ const stack = [{ dir: root, depth: 0 }];
32
+ let folders = 0;
18
33
  while (stack.length > 0) {
19
- const dir = stack.pop();
34
+ const { dir, depth } = stack.pop();
35
+ if (++folders > limits.maxFolders)
36
+ throw new DiscoveryLimitError();
20
37
  let entries;
21
38
  try {
22
39
  entries = readdirSync(dir, { withFileTypes: true });
@@ -27,29 +44,71 @@ function listJsonlFiles(root) {
27
44
  for (const entry of entries) {
28
45
  const full = join(dir, entry.name);
29
46
  if (entry.isDirectory()) {
30
- stack.push(full);
47
+ if (depth + 1 > limits.maxDepth)
48
+ throw new DiscoveryLimitError();
49
+ stack.push({ dir: full, depth: depth + 1 });
31
50
  }
32
51
  else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
52
+ if (files.length >= limits.maxFiles)
53
+ throw new DiscoveryLimitError();
33
54
  files.push(full);
34
55
  }
35
56
  }
36
57
  }
37
58
  return files.sort();
38
59
  }
60
+ /** Read the first line of a file without reading past `PI_HEADER_READ_LIMIT` bytes. */
61
+ export function readFirstLine(file, limit = PI_HEADER_READ_LIMIT) {
62
+ const fd = openSync(file, "r");
63
+ try {
64
+ const buffer = Buffer.alloc(limit);
65
+ const read = readSync(fd, buffer, 0, limit, 0);
66
+ const text = buffer.subarray(0, read).toString("utf8");
67
+ const newline = text.indexOf("\n");
68
+ return newline === -1 ? text : text.slice(0, newline);
69
+ }
70
+ finally {
71
+ closeSync(fd);
72
+ }
73
+ }
39
74
  function readHeaderLine(file) {
40
- let firstLine;
41
75
  try {
42
- const stream = readFileSync(file, "utf8");
43
- const newline = stream.indexOf("\n");
44
- firstLine = (newline === -1 ? stream : stream.slice(0, newline)).trim();
76
+ const firstLine = readFirstLine(file).trim();
45
77
  if (!firstLine)
46
78
  return { error: "empty file" };
47
79
  return { header: JSON.parse(firstLine) };
48
80
  }
49
81
  catch (error) {
50
82
  if (error instanceof SyntaxError)
51
- return { error: `malformed header: ${String(error)}` };
52
- return { error: `unreadable: ${String(error)}` };
83
+ return { error: "malformed header" };
84
+ return { error: `unreadable: ${error.code ?? "error"}` };
85
+ }
86
+ }
87
+ /** Parse one file's header, or say why the file is not a Pi session. */
88
+ export function readPiSessionHeader(file, key) {
89
+ const parsed = readHeaderLine(file);
90
+ if ("error" in parsed)
91
+ return { file, reason: parsed.error };
92
+ const header = parsed.header;
93
+ if (header?.type !== "session") {
94
+ return { file, reason: "not a Pi session file (no session header)" };
95
+ }
96
+ const timestamp = typeof header.timestamp === "string" ? Date.parse(header.timestamp) : NaN;
97
+ return {
98
+ file,
99
+ key,
100
+ sessionId: typeof header.id === "string" ? header.id : null,
101
+ cwd: typeof header.cwd === "string" ? header.cwd : null,
102
+ version: typeof header.version === "number" ? header.version : null,
103
+ timestamp: Number.isNaN(timestamp) ? null : timestamp,
104
+ };
105
+ }
106
+ function isFile(path) {
107
+ try {
108
+ return statSync(path).isFile();
109
+ }
110
+ catch {
111
+ return false;
53
112
  }
54
113
  }
55
114
  export function discoverPiSessions(options = {}) {
@@ -59,25 +118,18 @@ export function discoverPiSessions(options = {}) {
59
118
  if (!existsSync(root)) {
60
119
  return { sessions, unrecognized };
61
120
  }
62
- for (const file of listJsonlFiles(root)) {
63
- const parsed = readHeaderLine(file);
64
- if ("error" in parsed) {
65
- unrecognized.push({ file, reason: parsed.error });
66
- continue;
67
- }
68
- const header = parsed.header;
69
- if (header?.type !== "session") {
70
- unrecognized.push({ file, reason: "not a Pi session file (no session header)" });
71
- continue;
72
- }
73
- const timestamp = typeof header.timestamp === "string" ? Date.parse(header.timestamp) : NaN;
74
- sessions.push({
121
+ const files = isFile(root)
122
+ ? [{ file: root, key: basename(root) }]
123
+ : listJsonlFiles(root, options.limits ?? DEFAULT_DISCOVERY_LIMITS).map((file) => ({
75
124
  file,
76
- sessionId: typeof header.id === "string" ? header.id : null,
77
- cwd: typeof header.cwd === "string" ? header.cwd : null,
78
- version: typeof header.version === "number" ? header.version : null,
79
- timestamp: Number.isNaN(timestamp) ? null : timestamp,
80
- });
125
+ key: relative(root, file).split(sep).join("/"),
126
+ }));
127
+ for (const { file, key } of files) {
128
+ const result = readPiSessionHeader(file, key);
129
+ if ("reason" in result)
130
+ unrecognized.push(result);
131
+ else
132
+ sessions.push(result);
81
133
  }
82
134
  // Chronological order, oldest first; files without a timestamp sort last.
83
135
  sessions.sort((a, b) => (a.timestamp ?? Infinity) - (b.timestamp ?? Infinity));
@@ -7,7 +7,7 @@ import type { ImportPathMap } from "./importer.js";
7
7
  * external API and key).
8
8
  */
9
9
  export type ImportHost = "pi" | "opencode";
10
- export type ImportSurface = "session" | "cli";
10
+ export type ImportSurface = "session" | "cli" | "web";
11
11
  export interface HistoryImportArgs {
12
12
  help: boolean;
13
13
  dryRun: boolean;
@@ -39,6 +39,28 @@ export declare function parseHistoryImportArgs(tokens: string[], options: {
39
39
  host: ImportHost;
40
40
  surface: ImportSurface;
41
41
  }): HistoryImportArgs;
42
+ /**
43
+ * Options the Settings page sends. Sessions come from the page's selection
44
+ * and the source from a signed token, so there is no `session`,
45
+ * `maxSessions`, or `source` here.
46
+ */
47
+ export interface WebImportOptions {
48
+ dryRun?: boolean;
49
+ force?: boolean;
50
+ skipMemories?: boolean;
51
+ skipProfile?: boolean;
52
+ model?: string;
53
+ scope?: HistoryImportArgs["scope"];
54
+ project?: string;
55
+ /** Epoch ms from the browser's local day start, or an ISO date. */
56
+ since?: string | number;
57
+ /** Epoch ms from the browser's local day end, or an ISO date. */
58
+ until?: string | number;
59
+ profileBatch?: number;
60
+ pathMaps?: ImportPathMap[];
61
+ }
62
+ /** Convert page fields to the shared CLI grammar, which performs validation. */
63
+ export declare function webImportTokens(options: WebImportOptions, _host: ImportHost): string[];
42
64
  /** True when the run calls a model: a real import of memories or the profile. */
43
65
  export declare function importNeedsModel(args: HistoryImportArgs): boolean;
44
66
  export declare function historyImportUsage(host: ImportHost, surface: ImportSurface): string;
@@ -168,7 +168,7 @@ export function parseHistoryImportArgs(tokens, options) {
168
168
  if (result.project !== undefined && result.scope === "all-projects") {
169
169
  result.errors.push("--project cannot be combined with --scope=all-projects");
170
170
  }
171
- if (options.surface === "session" && result.model !== undefined) {
171
+ if (options.surface !== "cli" && result.model !== undefined) {
172
172
  const separator = result.model.indexOf("/");
173
173
  if (separator < 1 || separator === result.model.length - 1) {
174
174
  result.errors.push(`--model must be provider/id, got "${result.model}"`);
@@ -176,6 +176,34 @@ export function parseHistoryImportArgs(tokens, options) {
176
176
  }
177
177
  return result;
178
178
  }
179
+ /** Convert page fields to the shared CLI grammar, which performs validation. */
180
+ export function webImportTokens(options, _host) {
181
+ const tokens = [];
182
+ for (const [field, flag] of [
183
+ ["dryRun", "--dry-run"],
184
+ ["force", "--force"],
185
+ ["skipMemories", "--skip-memories"],
186
+ ["skipProfile", "--skip-profile"],
187
+ ]) {
188
+ if (options[field])
189
+ tokens.push(flag);
190
+ }
191
+ for (const [field, flag] of [
192
+ ["model", "--model"],
193
+ ["scope", "--scope"],
194
+ ["project", "--project"],
195
+ ["since", "--since"],
196
+ ["until", "--until"],
197
+ ["profileBatch", "--profile-batch"],
198
+ ]) {
199
+ const value = options[field];
200
+ if (value !== undefined)
201
+ tokens.push(flag, String(value));
202
+ }
203
+ for (const map of options.pathMaps ?? [])
204
+ tokens.push("--map", `${map.from}=${map.to}`);
205
+ return tokens;
206
+ }
179
207
  /** True when the run calls a model: a real import of memories or the profile. */
180
208
  export function importNeedsModel(args) {
181
209
  return !args.dryRun && (!args.skipMemories || !args.skipProfile);
@@ -0,0 +1,16 @@
1
+ import type { ImportPathMap } from "./importer.js";
2
+ /** How a recorded session directory was turned into a project directory. */
3
+ export type ProjectResolution = "mapped" | "recorded" | "worktree" | "unresolved";
4
+ export interface ResolvedImportProject {
5
+ directory: string | null;
6
+ via: ProjectResolution;
7
+ }
8
+ /**
9
+ * One rule for both hosts, shared by the web session list and the importers so
10
+ * the listed set and the imported set always agree. An exact directory map
11
+ * wins, because it is an explicit user choice; a map whose target is missing
12
+ * leaves the session unresolved rather than silently falling back. OpenCode
13
+ * alone passes a project worktree, which stands in for a deleted sub-directory
14
+ * of the same project.
15
+ */
16
+ export declare function resolveImportProject(recordedDirectory: string | null, maps?: ImportPathMap[], projectWorktree?: string | null): ResolvedImportProject;
@@ -0,0 +1,35 @@
1
+ import { statSync } from "node:fs";
2
+ function isDirectory(path) {
3
+ if (!path)
4
+ return false;
5
+ try {
6
+ return statSync(path).isDirectory();
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ /**
13
+ * One rule for both hosts, shared by the web session list and the importers so
14
+ * the listed set and the imported set always agree. An exact directory map
15
+ * wins, because it is an explicit user choice; a map whose target is missing
16
+ * leaves the session unresolved rather than silently falling back. OpenCode
17
+ * alone passes a project worktree, which stands in for a deleted sub-directory
18
+ * of the same project.
19
+ */
20
+ export function resolveImportProject(recordedDirectory, maps = [], projectWorktree = null) {
21
+ if (!recordedDirectory)
22
+ return { directory: null, via: "unresolved" };
23
+ const map = maps.find((item) => item.from === recordedDirectory);
24
+ if (map) {
25
+ return isDirectory(map.to)
26
+ ? { directory: map.to, via: "mapped" }
27
+ : { directory: null, via: "unresolved" };
28
+ }
29
+ if (isDirectory(recordedDirectory))
30
+ return { directory: recordedDirectory, via: "recorded" };
31
+ if (projectWorktree && projectWorktree !== "/" && isDirectory(projectWorktree)) {
32
+ return { directory: projectWorktree, via: "worktree" };
33
+ }
34
+ return { directory: null, via: "unresolved" };
35
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What a real web import could use right now, checked inside the OpenCode
3
+ * process that runs it. `env://` and `file://` keys were resolved when the
4
+ * config loaded, so a variable set only in the user's shell shows as missing.
5
+ * "Ready" means configured, not tested: the Health section runs test calls.
6
+ */
7
+ export type ExternalApiState = "ready" | "missing-model" | "missing-url" | "missing-key" | "unsupported-provider";
8
+ export interface ImportReadiness {
9
+ external: {
10
+ state: ExternalApiState;
11
+ provider: string;
12
+ model: string | null;
13
+ };
14
+ opencode: {
15
+ available: boolean;
16
+ models: Array<{
17
+ provider: string;
18
+ model: string;
19
+ name: string;
20
+ }>;
21
+ };
22
+ piReader: {
23
+ available: boolean;
24
+ reason?: string;
25
+ };
26
+ }
27
+ export interface ReadinessDeps {
28
+ supportedProviders?: () => Promise<string[]>;
29
+ listOpencodeModels?: () => Promise<{
30
+ available: boolean;
31
+ models?: Array<{
32
+ provider: string;
33
+ model: string;
34
+ name: string;
35
+ }>;
36
+ }>;
37
+ loadPiSdk?: () => Promise<unknown>;
38
+ }
39
+ /** Mirrors the checks in `selectImportModel`, without building a provider. */
40
+ export declare function externalApiState(supportedProviders?: () => Promise<string[]>): Promise<ExternalApiState>;
41
+ export declare function importReadiness(deps?: ReadinessDeps): Promise<ImportReadiness>;
42
+ /** Why a job cannot start, or `null` when it can. */
43
+ export declare function importBlockedReason(readiness: ImportReadiness, request: {
44
+ host: "pi" | "opencode";
45
+ needsModel: boolean;
46
+ modelChoice?: string;
47
+ }): string | null;