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.
- package/dist/adapters/opencode/auto-capture-summary.js +50 -8
- package/dist/adapters/opencode/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +3 -54
- package/dist/adapters/pi/extension.js +3 -0
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/adapters/pi/provider.js +41 -14
- package/dist/config.d.ts +18 -1
- package/dist/config.js +103 -9
- package/dist/core/capture-context.js +1 -1
- package/dist/core/capture.d.ts +4 -0
- package/dist/core/capture.js +30 -0
- package/dist/core/extraction.d.ts +23 -1
- package/dist/core/extraction.js +41 -0
- package/dist/core/host.d.ts +23 -0
- package/dist/core/host.js +9 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/discovery.d.ts +32 -5
- package/dist/importer/discovery.js +88 -36
- package/dist/importer/import-args.d.ts +23 -1
- package/dist/importer/import-args.js +29 -1
- package/dist/importer/import-project.d.ts +16 -0
- package/dist/importer/import-project.js +35 -0
- package/dist/importer/import-readiness.d.ts +47 -0
- package/dist/importer/import-readiness.js +54 -0
- package/dist/importer/import-sessions.d.ts +98 -0
- package/dist/importer/import-sessions.js +222 -0
- package/dist/importer/import-sources.d.ts +47 -0
- package/dist/importer/import-sources.js +147 -0
- package/dist/importer/importer.d.ts +31 -1
- package/dist/importer/importer.js +82 -24
- package/dist/importer/opencode-import.d.ts +11 -1
- package/dist/importer/opencode-import.js +133 -69
- package/dist/importer/opencode-project.d.ts +1 -4
- package/dist/importer/opencode-project.js +3 -36
- package/dist/importer/opencode-reader.d.ts +25 -5
- package/dist/importer/opencode-reader.js +92 -106
- package/dist/importer/opencode-snapshot.d.ts +82 -0
- package/dist/importer/opencode-snapshot.js +276 -0
- package/dist/importer/profile-import.d.ts +1 -0
- package/dist/importer/profile-import.js +7 -1
- package/dist/importer/run-import.d.ts +10 -0
- package/dist/importer/run-import.js +21 -0
- package/dist/importer/settings-health.d.ts +25 -0
- package/dist/importer/settings-health.js +136 -0
- package/dist/importer/web-import-api.d.ts +8 -0
- package/dist/importer/web-import-api.js +8 -0
- package/dist/importer/web-import-jobs.d.ts +53 -0
- package/dist/importer/web-import-jobs.js +227 -0
- package/dist/index.js +4 -0
- package/dist/services/ai/live-model-choice.js +6 -3
- package/dist/services/ai/opencode-import-models.d.ts +11 -0
- package/dist/services/ai/opencode-import-models.js +55 -0
- package/dist/services/ai/opencode-provider.d.ts +9 -0
- package/dist/services/ai/opencode-provider.js +31 -4
- package/dist/services/ai/providers/anthropic-messages.js +14 -6
- package/dist/services/ai/providers/base-provider.d.ts +8 -0
- package/dist/services/ai/providers/base-provider.js +12 -0
- package/dist/services/ai/providers/google-gemini.js +21 -7
- package/dist/services/ai/providers/openai-chat-completion.js +20 -7
- package/dist/services/ai/providers/openai-responses.js +11 -0
- package/dist/services/capture-attempt-store.d.ts +21 -0
- package/dist/services/capture-attempt-store.js +108 -0
- package/dist/services/capture-diagnostics.d.ts +66 -0
- package/dist/services/capture-diagnostics.js +175 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.d.ts +10 -0
- package/dist/services/global-config-writer.js +106 -0
- package/dist/services/log-path.d.ts +2 -0
- package/dist/services/log-path.js +15 -0
- package/dist/services/logger.js +1 -12
- package/dist/services/settings-log.d.ts +5 -0
- package/dist/services/settings-log.js +36 -0
- package/dist/services/settings-models.d.ts +35 -0
- package/dist/services/settings-models.js +39 -0
- package/dist/services/settings-snapshot.d.ts +24 -0
- package/dist/services/settings-snapshot.js +71 -0
- package/dist/services/settings-traces.d.ts +7 -0
- package/dist/services/settings-traces.js +39 -0
- package/dist/services/user-memory-learning.js +2 -1
- package/dist/services/web-server.d.ts +5 -0
- package/dist/services/web-server.js +176 -3
- package/dist/v2/legacy-client.js +19 -2
- package/dist/web/assets/index-BMkPht7g.js +124 -0
- package/dist/web/assets/index-D5ak5KsI.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DPjEpD1h.css +0 -2
- package/dist/web/assets/index-do26c4JJ.js +0 -122
package/dist/core/capture.js
CHANGED
|
@@ -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;
|
package/dist/core/extraction.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/host.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
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,
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
2
|
-
import {
|
|
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,
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
52
|
-
return { error: `unreadable: ${
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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;
|