om-memory-system 3.2.0-next.10 → 3.2.0-next.12
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/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +3 -54
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/config.d.ts +11 -1
- package/dist/config.js +82 -9
- package/dist/core/capture.js +2 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/discovery.d.ts +30 -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 +1 -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/capture-attempt-store.d.ts +21 -0
- package/dist/services/capture-attempt-store.js +108 -0
- package/dist/services/capture-diagnostics.js +3 -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/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/web/assets/index-BO6gokx9.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
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type OpencodeModelRef } from "../../services/ai/opencode-import-models.js";
|
|
2
|
+
export { createOpencodeImportModels } from "../../services/ai/opencode-import-models.js";
|
|
3
|
+
export type { OpencodeModelRef } from "../../services/ai/opencode-import-models.js";
|
|
2
4
|
/**
|
|
3
5
|
* OpenCode command surface for the historical-session importer:
|
|
4
6
|
*
|
|
@@ -12,10 +14,6 @@ import { type HistoryImportModels } from "../../importer/run-import.js";
|
|
|
12
14
|
export declare const OPENCODE_IMPORT_COMMAND = "memory-import-opencode-history";
|
|
13
15
|
export declare const OPENCODE_IMPORT_DESCRIPTION = "Import historical OpenCode sessions into the shared memory store (try --dry-run first)";
|
|
14
16
|
export declare const OPENCODE_IMPORT_USAGE: string;
|
|
15
|
-
export interface OpencodeModelRef {
|
|
16
|
-
providerID: string;
|
|
17
|
-
modelID: string;
|
|
18
|
-
}
|
|
19
17
|
export interface OpencodeImportCommandInput {
|
|
20
18
|
argsText: string;
|
|
21
19
|
/** Session directory; relative paths and the default project resolve here. */
|
|
@@ -24,8 +22,6 @@ export interface OpencodeImportCommandInput {
|
|
|
24
22
|
sessionModel: () => Promise<OpencodeModelRef | null>;
|
|
25
23
|
notify?: (message: string) => void;
|
|
26
24
|
}
|
|
27
|
-
/** Capture and profile calls routed through OpenCode's structured-output sessions. */
|
|
28
|
-
export declare function createOpencodeImportModels(ref: OpencodeModelRef, directory: string): Promise<Required<HistoryImportModels>>;
|
|
29
25
|
/** Parse, run, and describe one import; always resolves to text for the session. */
|
|
30
26
|
export declare function runOpencodeImportCommand(input: OpencodeImportCommandInput): Promise<string>;
|
|
31
27
|
/** The model of the session's latest user message, from the V1 message list. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { buildBoundedSummaryPrompt } from "../../core/capture-context.js";
|
|
1
|
+
import { isConfigured } from "../../config.js";
|
|
3
2
|
import { historyImportUsage, importNeedsModel, parseHistoryImportArgs, tokenizeImportArgs, } from "../../importer/import-args.js";
|
|
4
3
|
import { formatHistoryImportReport, summarizeHistoryImportReport, runHistoryImport, } from "../../importer/run-import.js";
|
|
4
|
+
import { createOpencodeImportModels, } from "../../services/ai/opencode-import-models.js";
|
|
5
|
+
export { createOpencodeImportModels } from "../../services/ai/opencode-import-models.js";
|
|
5
6
|
import { loadOpencodeProvider } from "../../services/ai/opencode-provider-loader.js";
|
|
6
7
|
import { memoryClient } from "../../services/client.js";
|
|
7
8
|
import { log } from "../../services/logger.js";
|
|
@@ -23,58 +24,6 @@ function splitModel(value) {
|
|
|
23
24
|
const separator = value.indexOf("/");
|
|
24
25
|
return { providerID: value.slice(0, separator), modelID: value.slice(separator + 1) };
|
|
25
26
|
}
|
|
26
|
-
/** Capture and profile calls routed through OpenCode's structured-output sessions. */
|
|
27
|
-
export async function createOpencodeImportModels(ref, directory) {
|
|
28
|
-
const { getV2Client, generateStructuredOutput } = await loadOpencodeProvider();
|
|
29
|
-
const client = getV2Client();
|
|
30
|
-
if (!client)
|
|
31
|
-
throw new Error("the OpenCode client is not ready; retry in a moment");
|
|
32
|
-
const { z } = await import("zod");
|
|
33
|
-
const { buildCaptureSystemPrompt, createUserProfileAnalysisSchema, parseCaptureSummary } = await import("../../core/extraction.js");
|
|
34
|
-
const { detectLanguage, getLanguageName } = await import("../../services/language-detector.js");
|
|
35
|
-
const captureSchema = z.object({
|
|
36
|
-
summary: z.string().optional(),
|
|
37
|
-
type: z.string(),
|
|
38
|
-
tags: z.array(z.string()).optional(),
|
|
39
|
-
});
|
|
40
|
-
const capture = {
|
|
41
|
-
async summarize(request) {
|
|
42
|
-
const target = CONFIG.autoCaptureLanguage && CONFIG.autoCaptureLanguage !== "auto"
|
|
43
|
-
? CONFIG.autoCaptureLanguage
|
|
44
|
-
: detectLanguage(request.userPrompt);
|
|
45
|
-
const systemPrompt = buildCaptureSystemPrompt(getLanguageName(target));
|
|
46
|
-
const result = await generateStructuredOutput({
|
|
47
|
-
client,
|
|
48
|
-
...ref,
|
|
49
|
-
systemPrompt,
|
|
50
|
-
userPrompt: buildBoundedSummaryPrompt(request.context, systemPrompt, z.toJSONSchema(captureSchema)),
|
|
51
|
-
schema: captureSchema,
|
|
52
|
-
directory,
|
|
53
|
-
});
|
|
54
|
-
const parsed = parseCaptureSummary(JSON.stringify(result));
|
|
55
|
-
if (!parsed)
|
|
56
|
-
throw new Error("History capture returned an invalid summary");
|
|
57
|
-
return parsed;
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
const profileSchema = createUserProfileAnalysisSchema(z);
|
|
61
|
-
const profile = {
|
|
62
|
-
provider: ref.providerID,
|
|
63
|
-
modelId: ref.modelID,
|
|
64
|
-
async complete(systemPrompt, userPrompt) {
|
|
65
|
-
const result = await generateStructuredOutput({
|
|
66
|
-
client,
|
|
67
|
-
...ref,
|
|
68
|
-
systemPrompt,
|
|
69
|
-
userPrompt,
|
|
70
|
-
schema: profileSchema,
|
|
71
|
-
directory,
|
|
72
|
-
});
|
|
73
|
-
return JSON.stringify(result);
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
return { capture, profile };
|
|
77
|
-
}
|
|
78
27
|
/** Parse, run, and describe one import; always resolves to text for the session. */
|
|
79
28
|
export async function runOpencodeImportCommand(input) {
|
|
80
29
|
const name = OPENCODE_IMPORT_COMMAND;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG } from "../../config.js";
|
|
1
|
+
import { CONFIG, refreshConfigIfChanged } from "../../config.js";
|
|
2
2
|
import { analyzeProfile, } from "../../core/profile-analysis.js";
|
|
3
3
|
import { log } from "../../services/logger.js";
|
|
4
4
|
import { getTags } from "../../services/tags.js";
|
|
@@ -47,6 +47,7 @@ export async function performPiProfileLearning(input) {
|
|
|
47
47
|
if (input.prompts.length === 0)
|
|
48
48
|
return;
|
|
49
49
|
try {
|
|
50
|
+
refreshConfigIfChanged(input.directory);
|
|
50
51
|
const tags = getTags(input.directory);
|
|
51
52
|
if (!tags.user.userEmail) {
|
|
52
53
|
log("pi profile learning: skipped (no user email resolved)", {
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ interface OmmsConfig {
|
|
|
41
41
|
/** Write full capture prompts and replies to ~/.omms/traces. Global config only. */
|
|
42
42
|
captureTrace?: boolean;
|
|
43
43
|
captureTraceRetentionDays?: number;
|
|
44
|
+
captureAttemptRetentionDays?: number;
|
|
44
45
|
webServerEnabled?: boolean;
|
|
45
46
|
webServerPort?: number;
|
|
46
47
|
webServerHost?: string;
|
|
@@ -88,6 +89,7 @@ interface OmmsConfig {
|
|
|
88
89
|
injectOn?: "first" | "always";
|
|
89
90
|
};
|
|
90
91
|
}
|
|
92
|
+
export declare const CONFIG_TEMPLATE = "{\n // ============================================\n // omms (Opinionated Modular Memory System) Configuration\n // ============================================\n \n // Storage location for the vector database. Leave unset to use the\n // default ~/.omms/data (a legacy ~/.opencode-mem/data store is migrated\n // there automatically on first start; see docs/omms-migration.md).\n // \"storagePath\": \"~/.omms/data\",\n\n \"userEmailOverride\": \"\",\n \"userNameOverride\": \"\",\n \n // ============================================\n // Embedding Model (for similarity search)\n // ============================================\n // Local = Hugging Face / ONNX via @huggingface/transformers (not Apple MLX).\n // Remote = set BOTH embeddingApiUrl and embeddingApiKey (OpenAI-compatible /embeddings).\n \n // Default: Nomic Embed v1 (768 dimensions, 8192 context, multilingual)\n \"embeddingModel\": \"Xenova/nomic-embed-text-v1\",\n\n // Opt-in Nomic task prefixes (search_document: / search_query:). After enabling,\n // re-index existing memories so store and query vectors stay aligned.\n // \"embeddingUseTaskPrefixes\": true,\n \n // Auto-detected dimensions (no need to set manually)\n // \"embeddingDimensions\": 768,\n \n // Other recommended local models:\n // \"embeddingModel\": \"Xenova/jina-embeddings-v2-base-en\", // 768 dims, English-only, 8192 context\n // \"embeddingModel\": \"Xenova/jina-embeddings-v2-small-en\", // 512 dims, faster, 8192 context\n // \"embeddingModel\": \"Xenova/all-MiniLM-L6-v2\", // 384 dims, very fast, 512 context\n // \"embeddingModel\": \"Xenova/all-mpnet-base-v2\", // 768 dims, good quality, 512 context\n \n // Optional: OpenAI-compatible API for embeddings (both URL and key required)\n // \"embeddingApiUrl\": \"https://api.openai.com/v1\",\n // \"embeddingApiKey\": \"env://OPENAI_API_KEY\", // or \"sk-...\" / \"file:///path/to/key\"\n // \"embeddingModel\": \"text-embedding-3-small\", // 1536 dims, auto-detected\n \n // ============================================\n // Web Server Settings\n // ============================================\n \n // Enable web UI for managing memories (accessible at http://localhost:4747)\n \"webServerEnabled\": true,\n \n // Port for web UI server\n \"webServerPort\": 4747,\n \n // Host address for web UI (use 127.0.0.1 for local only, 0.0.0.0 for network access)\n \"webServerHost\": \"127.0.0.1\",\n\n // Optional HTTP Basic Auth for the web UI. Accepts literal, env://, or file:// secrets.\n // \"webServerAuthPassword\": \"\",\n // \"webServerAuthUsername\": \"\",\n\n // Required when webServerHost is not loopback. Protects /api/* with Bearer / X-Opencode-Mem-Token.\n // \"webServerApiToken\": \"env://OMMS_WEB_TOKEN\",\n \n // ============================================\n // Database Settings\n // ============================================\n \n // Maximum vectors per database shard (auto-creates new shard when limit reached)\n \"maxVectorsPerShard\": 50000,\n \n // Automatically delete old memories based on retention period\n \"autoCleanupEnabled\": true,\n \n // Days to keep memories before auto-cleanup (only if autoCleanupEnabled is true)\n \"autoCleanupRetentionDays\": 30,\n \n // Automatically detect and remove duplicate memories\n \"deduplicationEnabled\": true,\n \n // Similarity threshold (0-1) for detecting duplicates (higher = stricter)\n \"deduplicationSimilarityThreshold\": 0.90,\n \n // ============================================\n // Memory Scope Settings\n // ============================================\n\n // Default scope for memory list/search queries\n // \"project\" keeps queries within the current project, \"all-projects\" searches across all project shards\n \"memory\": {\n \"defaultScope\": \"project\"\n },\n\n // ============================================\n // Model for auto-capture and profile learning (same rule in OpenCode and Pi)\n // ============================================\n\n // Which model summarises your work, in this order:\n // 1. The host model below: opencodeProvider/opencodeModel in OpenCode,\n // piProvider/piModel in Pi. Set the model to \"inherit\" to follow\n // whatever model the session is using.\n // 2. If no host model is set: the external API (memoryModel/memoryApiUrl/\n // memoryApiKey further down).\n // 3. If neither is set: the session's own model.\n // If the host model fails and the external API is configured, the external\n // API is used instead.\n //\n // Host models go through OpenCode's or Pi's own sign-in (OAuth like Claude\n // Pro/Max, GitHub Copilot, ChatGPT, or your own key there), so no separate API\n // key is needed here.\n //\n // Examples (OpenCode names from 'opencode providers list'; Pi names from its model list):\n // \"opencodeProvider\": \"anthropic\", \"opencodeModel\": \"claude-haiku-4-5-20251001\"\n // \"opencodeProvider\": \"openai\", \"opencodeModel\": \"inherit\"\n // \"piProvider\": \"openai-codex\", \"piModel\": \"gpt-5.6-luna\"\n //\n // \"opencodeProvider\": \"anthropic\",\n // \"opencodeModel\": \"claude-haiku-4-5-20251001\",\n // \"piProvider\": \"openai-codex\",\n // \"piModel\": \"gpt-5.6-luna\",\n\n // ============================================\n // Auto-Capture Settings\n // ============================================\n \n // Auto-capture runs in the background without blocking your main session.\n // Note: Ollama may not support tool calling. Use OpenAI, Anthropic, or Groq for best results.\n \n \"autoCaptureEnabled\": true,\n \n // Provider type: \"openai-chat\" | \"openai-responses\" | \"anthropic\" | \"minimax\" | \"orcarouter\"\n // Note: \"openai-chat\" is a generic OpenAI API-compatible mode.\n // Any service that follows the OpenAI Chat Completions API can use it via custom \"memoryApiUrl\".\n \"memoryProvider\": \"openai-chat\",\n \n // External API (step 2 above, and the fallback when a host model fails).\n // Uncomment all 3 lines and replace memoryApiKey before use:\n // \"memoryModel\": \"gpt-4o-mini\",\n // \"memoryApiUrl\": \"https://api.openai.com/v1\",\n // \"memoryApiKey\": \"sk-...\",\n\n // API Key Formats:\n // Direct value: \"sk-...\"\n // From file: \"file://~/.config/litellm-key.txt\"\n // From env variable: \"env://LITELLM_API_KEY\"\n \n // Examples for different providers:\n // Any OpenAI-compatible endpoint can use the \"openai-chat\" provider pattern below.\n // Common examples: DeepSeek, Qwen (via Alibaba Cloud ModelStudio),\n // Zhipu GLM (BigModel platform), and Kimi (Moonshot AI platform).\n\n // OpenAI Chat Completion (default, backward compatible):\n // \"memoryProvider\": \"openai-chat\"\n // \"memoryModel\": \"gpt-4o-mini\"\n // \"memoryApiUrl\": \"https://api.openai.com/v1\"\n // \"memoryApiKey\": \"sk-...\"\n\n // DeepSeek (OpenAI-compatible example):\n // \"memoryProvider\": \"openai-chat\"\n // \"memoryModel\": \"deepseek-chat\"\n // \"memoryApiUrl\": \"https://api.deepseek.com/v1\"\n // \"memoryApiKey\": \"sk-...\"\n \n // OpenAI Responses API (recommended, with session support):\n // \"memoryProvider\": \"openai-responses\"\n // \"memoryModel\": \"gpt-4o\"\n // \"memoryApiUrl\": \"https://api.openai.com/v1\"\n // \"memoryApiKey\": \"sk-...\"\n \n // Anthropic (with session support):\n // \"memoryProvider\": \"anthropic\"\n // \"memoryModel\": \"claude-3-5-haiku-20241022\"\n // \"memoryApiUrl\": \"https://api.anthropic.com/v1\"\n // \"memoryApiKey\": \"sk-ant-...\"\n\n // MiniMax (Anthropic Messages-compatible endpoint, with session support):\n // \"memoryProvider\": \"minimax\"\n // \"memoryModel\": \"MiniMax-M3\"\n // \"memoryApiUrl\": \"https://api.minimax.io\" // global endpoint\n // \"memoryApiKey\": \"<MiniMax API key>\"\n // // China endpoint: \"memoryApiUrl\": \"https://api.minimaxi.com\"\n // // Optional adaptive thinking for MiniMax-M3:\n // \"memoryExtraParams\": { \"thinking\": { \"type\": \"adaptive\" } }\n\n // OrcaRouter (OpenAI-compatible gateway, namespaced model IDs, with session support):\n // \"memoryProvider\": \"orcarouter\"\n // \"memoryApiKey\": \"<OrcaRouter API key>\"\n // // memoryApiUrl and memoryModel are optional \u2014 they default to\n // // https://api.orcarouter.ai/v1 and \"orcarouter/auto\" (a routing alias).\n // // OrcaRouter rejects bare model names, so if you set memoryModel, use a\n // // namespaced ID such as \"openai/gpt-5.5\" or \"deepseek/deepseek-v4-flash\".\n // \"memoryModel\": \"openai/gpt-5.5\"\n\n // Groq (OpenAI-compatible, use openai-chat provider):\n // \"memoryProvider\": \"openai-chat\"\n // \"memoryModel\": \"llama-3.3-70b-versatile\"\n // \"memoryApiUrl\": \"https://api.groq.com/openai/v1\"\n // \"memoryApiKey\": \"gsk_...\"\n \n // Maximum iterations for multi-turn AI analysis (for openai-responses, anthropic, and minimax)\n \"autoCaptureMaxIterations\": 5,\n \n // Timeout per iteration in milliseconds (30 seconds default)\n \"autoCaptureIterationTimeout\": 30000,\n\n // Maximum number of times to retry capturing a prompt if it fails (due to network, API errors, etc.)\n \"autoCaptureMaxRetries\": 3,\n\n // Maximum UTF-8 bytes for the auto-capture markdown context sent to the summary model.\n // Prevents HTTP 400 context overflows on models with ~131K token windows (e.g. Groq Llama).\n // Rough guide: tokens \u2248 bytes / 4 for mixed code/prose.\n \"autoCaptureMaxContextBytes\": 131072,\n \n // Days to keep AI session history before cleanup\n \"aiSessionRetentionDays\": 7,\n\n // Capture diagnostics: every capture attempt always writes one metadata line\n // (model, stop reason, sizes, outcome) to ~/.omms/omms.log, with no\n // conversation text. Set captureTrace to true to also write each attempt's\n // prompt and raw reply to ~/.omms/traces/ for debugging. Traces can contain\n // conversation content: <private> text and common API key formats are\n // redacted, files are readable only by you, and they are deleted after\n // captureTraceRetentionDays. Only this global file can turn tracing on.\n // \"captureTrace\": false,\n // \"captureTraceRetentionDays\": 7,\n // \"captureAttemptRetentionDays\": 30,\n\n // Temperature for AI API requests (set to false to omit parameter for models that don't support it)\n // Some reasoning models (like o1, o3, gpt-5) don't support temperature parameter\n // Set to false and add \"memoryTemperature\": false in config when using such models\n \"memoryTemperature\": 0.3,\n\n // Extra parameters to include in API request body\n // Useful for local inference servers (e.g. llama-server with --jinja) that support\n // additional parameters like disabling thinking/reasoning mode\n // Example for Qwen3 models: { \"enable_thinking\": false }\n // \"memoryExtraParams\": {},\n\n // Language for auto-capture summaries (default: \"auto\" for auto-detection)\n // Options: \"auto\", \"en\", \"id\", \"zh\", \"ja\", \"es\", \"fr\", \"de\", \"ru\", \"pt\", \"ar\", \"ko\"\n // \"autoCaptureLanguage\": \"auto\",\n\n // ============================================\n // Toast Notifications\n // ============================================\n\n // Show toast when memory is auto-captured\n \"showAutoCaptureToasts\": true,\n\n // Show toast when user profile is updated\n \"showUserProfileToasts\": true,\n\n // Show toast for error messages\n \"showErrorToasts\": true,\n\n // ============================================\n // User Profile System\n // ============================================\n\n // Analyze user prompts every N prompts to build/update your user profile\n // When N uncaptured prompts accumulate, AI will analyze them to identify:\n // - User preferences (code style, communication style, tool preferences)\n // - User patterns (recurring topics, problem domains, technical interests)\n // - User workflows (development habits, sequences, learning style)\n // - Skill level (overall and per-domain assessment)\n \"userProfileAnalysisInterval\": 10,\n\n // Days before inactive items (all types) are eligible for removal\n \"userProfileStaleDays\": 2,\n\n // Number of preferences shown in UI\n \"userProfileDisplayPreferences\": 20,\n \n // Number of patterns shown in UI\n \"userProfileDisplayPatterns\": 15,\n \n // Number of workflows shown in UI\n \"userProfileDisplayWorkflows\": 10,\n \n // Number of preferences injected into LLM conversation context\n // Keep this small \u2014 the strongest signals are enough; more dilute LLM attention\n \"userProfileInjectPreferences\": 5,\n \n // Number of patterns injected into LLM conversation context\n \"userProfileInjectPatterns\": 5,\n \n // Number of workflows injected into LLM conversation context\n \"userProfileInjectWorkflows\": 3,\n \n // Days before preference confidence starts to decay (if not reinforced)\n // Preferences that aren't seen again will gradually lose confidence and be removed\n \"userProfileConfidenceDecayDays\": 30,\n \n // Number of profile versions to keep in changelog (for rollback/debugging)\n // Older versions are automatically cleaned up\n \"userProfileChangelogRetentionCount\": 5,\n\n // Minimum evidence count for a preference/pattern to survive confidence decay\n // Items confirmed fewer times are more likely to be pruned when confidence decays\n \"userProfileMinEvidenceForRetention\": 3,\n\n // Periodically merge duplicate or irrelevant profile items with the configured AI provider\n \"userProfileAutoCleanupEnabled\": true,\n // Number of analyzed user prompts between automatic AI cleanup runs\n \"userProfileAutoCleanupInterval\": 100,\n\n // Enable LLM validation of existing preferences against recent behavior.\n // When enabled, each analysis round checks if top-5 preferences still match recent prompts.\n // Experimental \u2014 disabled by default.\n \"userProfileValidationEnabled\": false,\n\n // ============================================\n // Search Settings\n // ============================================\n \n // Minimum similarity score (0-1) for memory search results\n \"similarityThreshold\": 0.6,\n\n // Maximum number of memories to return in search results\n \"maxMemories\": 10,\n\n // ============================================\n // Advanced Settings\n // ============================================\n \n // Inject user profile into AI context (preferences, patterns, workflows)\n \"injectProfile\": true\n}\n";
|
|
91
93
|
export declare function normalizeAutoCaptureMaxContextBytes(value: number): number;
|
|
92
94
|
export declare function normalizeAutoCleanupRetentionDays(value: number): number;
|
|
93
95
|
declare function buildConfig(fileConfig: OmmsConfig): {
|
|
@@ -124,6 +126,7 @@ declare function buildConfig(fileConfig: OmmsConfig): {
|
|
|
124
126
|
aiSessionRetentionDays: number;
|
|
125
127
|
captureTrace: boolean;
|
|
126
128
|
captureTraceRetentionDays: number;
|
|
129
|
+
captureAttemptRetentionDays: number;
|
|
127
130
|
webServerEnabled: boolean;
|
|
128
131
|
webServerPort: number;
|
|
129
132
|
webServerHost: string;
|
|
@@ -174,6 +177,9 @@ declare function buildConfig(fileConfig: OmmsConfig): {
|
|
|
174
177
|
injectOn: "first" | "always";
|
|
175
178
|
};
|
|
176
179
|
};
|
|
180
|
+
export declare function getGlobalConfigSourcePath(): string | undefined;
|
|
181
|
+
export declare function getGlobalConfigWritePath(): string;
|
|
182
|
+
export declare function validateGlobalConfig(value: unknown): void;
|
|
177
183
|
export declare let CONFIG: {
|
|
178
184
|
storagePath: string;
|
|
179
185
|
userEmailOverride: string | undefined;
|
|
@@ -208,6 +214,7 @@ export declare let CONFIG: {
|
|
|
208
214
|
aiSessionRetentionDays: number;
|
|
209
215
|
captureTrace: boolean;
|
|
210
216
|
captureTraceRetentionDays: number;
|
|
217
|
+
captureAttemptRetentionDays: number;
|
|
211
218
|
webServerEnabled: boolean;
|
|
212
219
|
webServerPort: number;
|
|
213
220
|
webServerHost: string;
|
|
@@ -262,7 +269,10 @@ type RuntimeConfig = ReturnType<typeof buildConfig>;
|
|
|
262
269
|
export { getAutoCaptureProviderStatus, type AutoCaptureProviderStatus, } from "./services/ai/live-model-choice.js";
|
|
263
270
|
export { isPlaceholderApiKey };
|
|
264
271
|
export declare function hasAutoCaptureProviderConfig(config?: RuntimeConfig): boolean;
|
|
265
|
-
export declare function
|
|
272
|
+
export declare function refreshConfigIfChanged(directory: string): void;
|
|
273
|
+
export declare function initConfig(directory: string, options?: {
|
|
274
|
+
strict?: boolean;
|
|
275
|
+
}): void;
|
|
266
276
|
/**
|
|
267
277
|
* initConfig plus the one-time legacy store migration (decision D13).
|
|
268
278
|
*
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { stripJsoncComments } from "./services/jsonc.js";
|
|
@@ -50,6 +50,7 @@ const DEFAULTS = {
|
|
|
50
50
|
aiSessionRetentionDays: 7,
|
|
51
51
|
captureTrace: false,
|
|
52
52
|
captureTraceRetentionDays: 7,
|
|
53
|
+
captureAttemptRetentionDays: 30,
|
|
53
54
|
webServerEnabled: true,
|
|
54
55
|
webServerPort: 4747,
|
|
55
56
|
webServerHost: "127.0.0.1",
|
|
@@ -110,8 +111,9 @@ function expandPath(path) {
|
|
|
110
111
|
* Load the first config file that exists, in priority order. A file that
|
|
111
112
|
* exists but cannot be read or parsed is not skipped: falling through would let
|
|
112
113
|
* a lower-priority (legacy) file silently supply settings such as storagePath.
|
|
114
|
+
* `strict` (live reload) throws instead, so running hosts keep their settings.
|
|
113
115
|
*/
|
|
114
|
-
function loadConfigFromPaths(paths) {
|
|
116
|
+
function loadConfigFromPaths(paths, strict = false) {
|
|
115
117
|
const path = paths.find((candidate) => existsSync(candidate));
|
|
116
118
|
if (!path)
|
|
117
119
|
return {};
|
|
@@ -121,6 +123,9 @@ function loadConfigFromPaths(paths) {
|
|
|
121
123
|
return JSON.parse(json);
|
|
122
124
|
}
|
|
123
125
|
catch (error) {
|
|
126
|
+
// The parser's message can quote file content, so a reload names the file only.
|
|
127
|
+
if (strict)
|
|
128
|
+
throw new Error(`Config file cannot be parsed: ${path}`, { cause: error });
|
|
124
129
|
log("Config file is invalid; using defaults instead of lower-priority files", {
|
|
125
130
|
path,
|
|
126
131
|
error: String(error),
|
|
@@ -142,7 +147,7 @@ function assertProjectRemoteProviderConfigIsSafe(projectConfig) {
|
|
|
142
147
|
`Move them to the global config at ${CONFIG_FILES[0]}.`);
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
|
-
const CONFIG_TEMPLATE = `{
|
|
150
|
+
export const CONFIG_TEMPLATE = `{
|
|
146
151
|
// ============================================
|
|
147
152
|
// omms (Opinionated Modular Memory System) Configuration
|
|
148
153
|
// ============================================
|
|
@@ -363,6 +368,7 @@ const CONFIG_TEMPLATE = `{
|
|
|
363
368
|
// captureTraceRetentionDays. Only this global file can turn tracing on.
|
|
364
369
|
// "captureTrace": false,
|
|
365
370
|
// "captureTraceRetentionDays": 7,
|
|
371
|
+
// "captureAttemptRetentionDays": 30,
|
|
366
372
|
|
|
367
373
|
// Temperature for AI API requests (set to false to omit parameter for models that don't support it)
|
|
368
374
|
// Some reasoning models (like o1, o3, gpt-5) don't support temperature parameter
|
|
@@ -594,6 +600,7 @@ function buildConfig(fileConfig) {
|
|
|
594
600
|
aiSessionRetentionDays: fileConfig.aiSessionRetentionDays ?? DEFAULTS.aiSessionRetentionDays,
|
|
595
601
|
captureTrace: fileConfig.captureTrace === true,
|
|
596
602
|
captureTraceRetentionDays: Math.max(1, Math.floor(fileConfig.captureTraceRetentionDays ?? DEFAULTS.captureTraceRetentionDays)),
|
|
603
|
+
captureAttemptRetentionDays: normalizeAutoCleanupRetentionDays(fileConfig.captureAttemptRetentionDays ?? DEFAULTS.captureAttemptRetentionDays),
|
|
597
604
|
webServerEnabled: fileConfig.webServerEnabled ?? DEFAULTS.webServerEnabled,
|
|
598
605
|
webServerPort: fileConfig.webServerPort ?? DEFAULTS.webServerPort,
|
|
599
606
|
webServerHost: fileConfig.webServerHost ?? DEFAULTS.webServerHost,
|
|
@@ -652,6 +659,21 @@ function buildConfig(fileConfig) {
|
|
|
652
659
|
},
|
|
653
660
|
};
|
|
654
661
|
}
|
|
662
|
+
export function getGlobalConfigSourcePath() {
|
|
663
|
+
return CONFIG_FILES.find((candidate) => existsSync(candidate));
|
|
664
|
+
}
|
|
665
|
+
export function getGlobalConfigWritePath() {
|
|
666
|
+
const source = getGlobalConfigSourcePath();
|
|
667
|
+
return source && !LEGACY_CONFIG_FILES.includes(source)
|
|
668
|
+
? source
|
|
669
|
+
: join(OMMS_CONFIG_DIR, "omms.jsonc");
|
|
670
|
+
}
|
|
671
|
+
export function validateGlobalConfig(value) {
|
|
672
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
673
|
+
throw new Error("Global config must be an object");
|
|
674
|
+
}
|
|
675
|
+
buildConfig(value);
|
|
676
|
+
}
|
|
655
677
|
const _globalFileConfig = loadConfigFromPaths(CONFIG_FILES);
|
|
656
678
|
let lastFileConfig = _globalFileConfig;
|
|
657
679
|
export let CONFIG = buildConfig(_globalFileConfig);
|
|
@@ -660,16 +682,64 @@ export { isPlaceholderApiKey };
|
|
|
660
682
|
export function hasAutoCaptureProviderConfig(config = CONFIG) {
|
|
661
683
|
return getAutoCaptureProviderStatus(config).ready;
|
|
662
684
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const projectPaths = [
|
|
685
|
+
function projectConfigPaths(directory) {
|
|
686
|
+
return [
|
|
666
687
|
join(directory, ".opencode", "omms.jsonc"),
|
|
667
688
|
join(directory, ".opencode", "omms.json"),
|
|
668
689
|
join(directory, ".opencode", "opencode-mem.jsonc"),
|
|
669
690
|
join(directory, ".opencode", "opencode-mem.json"),
|
|
670
691
|
];
|
|
671
|
-
|
|
672
|
-
|
|
692
|
+
}
|
|
693
|
+
function configSignature(directory) {
|
|
694
|
+
return [...CONFIG_FILES, ...projectConfigPaths(directory)]
|
|
695
|
+
.filter((path) => existsSync(path))
|
|
696
|
+
.map((path) => {
|
|
697
|
+
try {
|
|
698
|
+
const stat = statSync(path);
|
|
699
|
+
return `${path}:${stat.mtimeMs}:${stat.size}`;
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (error.code === "ENOENT")
|
|
703
|
+
return `${path}:missing`;
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
})
|
|
707
|
+
.join("|");
|
|
708
|
+
}
|
|
709
|
+
let lastConfigDirectory;
|
|
710
|
+
let lastConfigSignature;
|
|
711
|
+
/** Directory and signature of the last reload that failed. */
|
|
712
|
+
let lastFailedConfig;
|
|
713
|
+
export function refreshConfigIfChanged(directory) {
|
|
714
|
+
// Both hosts initialise their config at startup. Direct callers that have
|
|
715
|
+
// not initialised it keep their in-memory settings untouched.
|
|
716
|
+
if (lastConfigDirectory === undefined)
|
|
717
|
+
return;
|
|
718
|
+
let attempt;
|
|
719
|
+
try {
|
|
720
|
+
const signature = configSignature(directory);
|
|
721
|
+
if (directory === lastConfigDirectory && signature === lastConfigSignature)
|
|
722
|
+
return;
|
|
723
|
+
attempt = `${directory}\0${signature}`;
|
|
724
|
+
if (attempt === lastFailedConfig)
|
|
725
|
+
return;
|
|
726
|
+
initConfig(directory, { strict: true });
|
|
727
|
+
lastFailedConfig = undefined;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
// A bad hand edit must not stop capture: keep the last good settings, and
|
|
731
|
+
// skip this exact file state until it changes, instead of re-reading it on
|
|
732
|
+
// every unit. The last good directory and signature stay as they were.
|
|
733
|
+
log("Config reload failed; keeping the previous settings", {
|
|
734
|
+
error: error instanceof Error ? error.message : String(error),
|
|
735
|
+
});
|
|
736
|
+
lastFailedConfig = attempt;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
export function initConfig(directory, options = {}) {
|
|
740
|
+
// omms project overrides win; the legacy opencode-mem file is still read.
|
|
741
|
+
const globalConfig = loadConfigFromPaths(CONFIG_FILES, options.strict);
|
|
742
|
+
const projectConfig = loadConfigFromPaths(projectConfigPaths(directory), options.strict);
|
|
673
743
|
assertProjectRemoteProviderConfigIsSafe(projectConfig);
|
|
674
744
|
const projectOverrides = { ...projectConfig };
|
|
675
745
|
delete projectOverrides.autoCleanupEnabled;
|
|
@@ -682,8 +752,11 @@ export function initConfig(directory) {
|
|
|
682
752
|
}
|
|
683
753
|
delete projectOverrides.captureTraceRetentionDays;
|
|
684
754
|
const merged = { ...globalConfig, ...projectOverrides };
|
|
755
|
+
const nextConfig = buildConfig(merged);
|
|
685
756
|
lastFileConfig = merged;
|
|
686
|
-
CONFIG =
|
|
757
|
+
CONFIG = nextConfig;
|
|
758
|
+
lastConfigDirectory = directory;
|
|
759
|
+
lastConfigSignature = configSignature(directory);
|
|
687
760
|
}
|
|
688
761
|
/**
|
|
689
762
|
* Rebuild CONFIG from the last loaded file config. Used after the legacy
|
package/dist/core/capture.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG } from "../config.js";
|
|
1
|
+
import { CONFIG, refreshConfigIfChanged } from "../config.js";
|
|
2
2
|
import { buildCaptureAttemptRecord, emitCaptureAttempt } from "../services/capture-diagnostics.js";
|
|
3
3
|
import { memoryClient } from "../services/client.js";
|
|
4
4
|
import { getTags } from "../services/tags.js";
|
|
@@ -22,6 +22,7 @@ async function getLatestProjectMemory(containerTag) {
|
|
|
22
22
|
* whether it is saved, skipped, or fails.
|
|
23
23
|
*/
|
|
24
24
|
export async function captureConversation(workUnit, provider) {
|
|
25
|
+
refreshConfigIfChanged(workUnit.projectDirectory);
|
|
25
26
|
const diagnostics = {};
|
|
26
27
|
const startedAt = Date.now();
|
|
27
28
|
let outcome = "failed";
|
|
@@ -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,27 @@ export interface DiscoveryResult {
|
|
|
24
30
|
sessions: DiscoveredPiSession[];
|
|
25
31
|
unrecognized: UnrecognizedFile[];
|
|
26
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Bounds on a folder walk. A Pi sessions folder is shallow (one folder per
|
|
35
|
+
* project), so a walk that goes past these is almost certainly a home folder,
|
|
36
|
+
* `/`, or a whole backup volume, and would block the process for minutes.
|
|
37
|
+
*/
|
|
38
|
+
export interface DiscoveryLimits {
|
|
39
|
+
maxDepth: number;
|
|
40
|
+
maxFolders: number;
|
|
41
|
+
maxFiles: number;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_DISCOVERY_LIMITS: DiscoveryLimits;
|
|
44
|
+
export declare class DiscoveryLimitError extends Error {
|
|
45
|
+
constructor();
|
|
46
|
+
}
|
|
27
47
|
export interface DiscoveryOptions {
|
|
28
48
|
root?: string;
|
|
29
49
|
maxSessions?: number;
|
|
50
|
+
limits?: DiscoveryLimits;
|
|
30
51
|
}
|
|
52
|
+
/** Read the first line of a file without reading past `PI_HEADER_READ_LIMIT` bytes. */
|
|
53
|
+
export declare function readFirstLine(file: string, limit?: number): string;
|
|
54
|
+
/** Parse one file's header, or say why the file is not a Pi session. */
|
|
55
|
+
export declare function readPiSessionHeader(file: string, key: string): DiscoveredPiSession | UnrecognizedFile;
|
|
31
56
|
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: 6,
|
|
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));
|