om-memory-system 4.12.0 → 4.13.3

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/README.md CHANGED
@@ -55,6 +55,12 @@ downloads a small embedding model (the part that makes memories searchable),
55
55
  so you need internet access once. The terminal import command also needs
56
56
  Node.js 22.14 or later.
57
57
 
58
+ > [!NOTE]
59
+ > OMMS is tested mostly on macOS and Linux. Windows runs only in the release
60
+ > check, so it gets less testing. If something breaks on Windows,
61
+ > [open an issue](https://github.com/cmdaltctr/omms/issues). If you fix it,
62
+ > a pull request is welcome.
63
+
58
64
  ## Set up
59
65
 
60
66
  ### 1. Install
@@ -4,4 +4,6 @@ import { type ClaudeStatusOptions } from "./status.js";
4
4
  * `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
5
5
  * block or break a Claude Code session, whatever goes wrong.
6
6
  */
7
- export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions & ClaudeStatusOptions): Promise<number>;
7
+ export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions & ClaudeStatusOptions & {
8
+ skipSelfUpdate?: boolean;
9
+ }): Promise<number>;
@@ -9,6 +9,18 @@ async function printStatus(io) {
9
9
  const status = await buildClaudeStatus({ ...inputs, fetch: io.fetch ?? globalThis.fetch });
10
10
  (io.writeStdout ?? ((text) => process.stdout.write(text)))(`${JSON.stringify(status)}\n`);
11
11
  }
12
+ /** Update the Claude plugin in the background when npm has a newer release. Never throws. */
13
+ async function selfUpdate(io) {
14
+ try {
15
+ const { startPluginSelfUpdate, pluginSelfUpdateDeps } = await import("./plugin-self-update.js");
16
+ const log = io.log ??
17
+ (async (message, data) => (await import("../../services/logger.js")).log(message, data));
18
+ await startPluginSelfUpdate(pluginSelfUpdateDeps(log));
19
+ }
20
+ catch {
21
+ /* The memory hook already ran; a failed update check changes nothing. */
22
+ }
23
+ }
12
24
  /**
13
25
  * `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
14
26
  * block or break a Claude Code session, whatever goes wrong.
@@ -22,6 +34,8 @@ export async function runClaudeHookCommand(argv, io = {}) {
22
34
  }
23
35
  if (isHookEvent(event)) {
24
36
  await runClaudeHook(event, io);
37
+ if (event === "session-start" && !io.skipSelfUpdate)
38
+ await selfUpdate(io);
25
39
  return 0;
26
40
  }
27
41
  // Only a known name reaches the log; argv is not trusted text.
@@ -0,0 +1,27 @@
1
+ /** A second session does not start another update for the same release within this time. */
2
+ export declare const SELF_UPDATE_RETRY_MS: number;
3
+ export type PluginSelfUpdateCode = "started" | "current" | "recent" | "disabled" | "no-plugin" | "unreadable-plugin" | "unreachable" | "spawn-failed";
4
+ export interface PluginSelfUpdateDeps {
5
+ /** False when `OMMS_DISABLE_UPDATE_CHECK` is `1`. */
6
+ enabled: boolean;
7
+ /** `CLAUDE_PLUGIN_ROOT`; undefined outside a Claude plugin. */
8
+ pluginRoot: string | undefined;
9
+ readPluginVersion: (root: string) => string;
10
+ fetch: typeof fetch;
11
+ now: () => number;
12
+ readMarker: () => {
13
+ version: string;
14
+ startedAt: number;
15
+ } | null;
16
+ writeMarker: (value: {
17
+ version: string;
18
+ startedAt: number;
19
+ }) => void;
20
+ /** Start a process that outlives this hook. */
21
+ spawnDetached: (command: string, args: string[]) => void;
22
+ log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
23
+ }
24
+ /** Start a background plugin update when npm has a newer release. Never throws. */
25
+ export declare function startPluginSelfUpdate(deps: PluginSelfUpdateDeps): Promise<PluginSelfUpdateCode>;
26
+ /** Production inputs: the real plugin manifest, npm, and a marker file in ~/.omms. */
27
+ export declare function pluginSelfUpdateDeps(log: PluginSelfUpdateDeps["log"], env?: NodeJS.ProcessEnv): PluginSelfUpdateDeps;
@@ -0,0 +1,103 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { availableUpdate, latestNpmVersion } from "../../services/update-check.js";
6
+ // Claude Code installs the OMMS plugin from the `stable` branch, but its own
7
+ // update pass runs at its own time and can leave a new session on the old
8
+ // plugin. At session start OMMS compares the plugin with npm `latest` and, when
9
+ // it is older, runs Claude Code's own update commands in the background. The
10
+ // new copy loads in the next session or after /reload-plugins.
11
+ /** A second session does not start another update for the same release within this time. */
12
+ export const SELF_UPDATE_RETRY_MS = 30 * 60 * 1000;
13
+ /** Node script that refreshes the marketplace, then updates the plugin. Run detached. */
14
+ const UPDATE_SCRIPT = `
15
+ const { spawnSync } = require("node:child_process");
16
+ const shell = process.platform === "win32";
17
+ for (const args of [["plugin", "marketplace", "update", "omms"], ["plugin", "update", "omms@omms"]]) {
18
+ const run = spawnSync("claude", args, { stdio: "ignore", shell, timeout: 120000 });
19
+ if (run.status !== 0) process.exit(1);
20
+ }
21
+ `;
22
+ /** Start a background plugin update when npm has a newer release. Never throws. */
23
+ export async function startPluginSelfUpdate(deps) {
24
+ const code = await decide(deps);
25
+ if (code.code === "started" || code.code === "spawn-failed") {
26
+ await deps.log("Claude plugin self-update", {
27
+ code: code.code,
28
+ plugin: code.plugin,
29
+ latest: code.latest,
30
+ });
31
+ }
32
+ return code.code;
33
+ }
34
+ async function decide(deps) {
35
+ if (!deps.enabled)
36
+ return { code: "disabled" };
37
+ if (!deps.pluginRoot)
38
+ return { code: "no-plugin" };
39
+ let plugin;
40
+ try {
41
+ plugin = deps.readPluginVersion(deps.pluginRoot);
42
+ }
43
+ catch {
44
+ return { code: "unreadable-plugin" };
45
+ }
46
+ const latest = await latestNpmVersion(deps.fetch);
47
+ if (latest === null)
48
+ return { code: "unreachable" };
49
+ const newer = availableUpdate(plugin, latest);
50
+ if (!newer)
51
+ return { code: "current" };
52
+ const marker = deps.readMarker();
53
+ const now = deps.now();
54
+ if (marker?.version === newer && now - marker.startedAt <= SELF_UPDATE_RETRY_MS) {
55
+ return { code: "recent" };
56
+ }
57
+ // Write first, so a session that starts during the update does not start another.
58
+ deps.writeMarker({ version: newer, startedAt: now });
59
+ try {
60
+ deps.spawnDetached(process.execPath, ["-e", UPDATE_SCRIPT]);
61
+ }
62
+ catch {
63
+ return { code: "spawn-failed", plugin, latest: newer };
64
+ }
65
+ return { code: "started", plugin, latest: newer };
66
+ }
67
+ /** Production inputs: the real plugin manifest, npm, and a marker file in ~/.omms. */
68
+ export function pluginSelfUpdateDeps(log, env = process.env) {
69
+ const markerPath = join(homedir(), ".omms", "claude-plugin-update.json");
70
+ return {
71
+ enabled: env.OMMS_DISABLE_UPDATE_CHECK !== "1",
72
+ pluginRoot: env.CLAUDE_PLUGIN_ROOT || undefined,
73
+ readPluginVersion: (root) => {
74
+ const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
75
+ if (typeof manifest.version !== "string")
76
+ throw new Error("no version");
77
+ return manifest.version;
78
+ },
79
+ fetch: globalThis.fetch,
80
+ now: Date.now,
81
+ readMarker: () => {
82
+ try {
83
+ const value = JSON.parse(readFileSync(markerPath, "utf8"));
84
+ return typeof value.version === "string" && typeof value.startedAt === "number"
85
+ ? { version: value.version, startedAt: value.startedAt }
86
+ : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ },
92
+ writeMarker: (value) => {
93
+ mkdirSync(dirname(markerPath), { recursive: true });
94
+ writeFileSync(markerPath, JSON.stringify(value));
95
+ },
96
+ spawnDetached: (command, args) => {
97
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
98
+ child.on("error", () => { });
99
+ child.unref();
100
+ },
101
+ log,
102
+ };
103
+ }
package/dist/config.d.ts CHANGED
@@ -76,6 +76,7 @@ interface OmmsConfig {
76
76
  userProfileDisplayPatterns?: number;
77
77
  userProfileDisplayWorkflows?: number;
78
78
  userProfileStaleDays?: number;
79
+ userProfileWorkflowStaleDays?: number;
79
80
  userProfileInjectPreferences?: number;
80
81
  userProfileInjectPatterns?: number;
81
82
  userProfileInjectWorkflows?: number;
@@ -106,7 +107,7 @@ interface OmmsConfig {
106
107
  injectOn?: "first" | "always";
107
108
  };
108
109
  }
109
- 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 embeddingApiUrl (OpenAI-compatible /embeddings); embeddingApiKey is optional.\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 (URL required; key only if the server needs one)\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 // Start a background import of past chats on each host's next start.\n \"autoBackfill\": true,\n // \"piBackfillModel\": \"inherit\", // or \"external\", or \"provider/model\"\n // \"opencodeBackfillModel\": \"inherit\", // or \"external\", or \"provider/model\"\n // Directory maps for history recorded in moved or deleted directories.\n // \"importPathMaps\": [{ \"from\": \"~/code/app-feat-x\", \"to\": \"~/code/app\" }],\n // Unresolved directories to hide on the Directory maps page. They stay unimported.\n // \"importIgnoredDirectories\": [\"/private/tmp/scratch\"],\n // Claude Code's folder, when it is not ~/.claude (like CLAUDE_CONFIG_DIR).\n // \"claudeConfigDir\": \"~/.claude-work\",\n\n // Register the web app to start when you log in.\n \"webServerAutoStart\": true,\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 // Deprecated: imported once into the API token table, then no longer read.\n // Create API tokens on the Settings page (Keys and access) instead.\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, or to \"external\" to send\n // every call to the external API below.\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\" | \"google-gemini\"\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 // Tries for one turn's capture, on Pi and OpenCode, 2 and 4 seconds apart. After the\n // last one fails, a turn the model could not reach goes to the capture retry queue.\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 // Capture retry queue: when a live capture fails because the model cannot be\n // reached (network error, timeout, HTTP 408, 429 or 5xx), OMMS keeps a copy of\n // the turn in ~/.omms/data and tries it again later. The copy has <private>\n // text and common API key formats removed. Turns are deleted when the retry\n // ends or after captureRetryRetentionHours (0 to 720). 0 turns the queue off.\n // Only this global file can set it.\n // \"captureRetryRetentionHours\": 72,\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 // Manual searches can ask for fewer; prompt retrieval uses this ceiling.\n \"maxMemories\": 10,\n\n // ============================================\n // Memory Context Controls\n // ============================================\n\n // Recent memories added at fresh session start (OpenCode V1 and Claude Code).\n // Pi and OpenCode V2 search the store with each prompt instead.\n \"chatMessage\": {\n \"enabled\": true,\n // Recent memories injected at session start.\n \"maxMemories\": 3,\n \"excludeCurrentSession\": true,\n // When to inject: \"first\" message of a session or \"always\".\n // \"injectOn\": \"first\",\n },\n\n // OpenCode profile-learning input limit, in UTF-8 bytes. Smaller values can\n // omit prompts from that input. Does not limit Pi or Claude Code profile input.\n \"userProfileMaxContextBytes\": 32768,\n\n // Shared budget for automatic memory context (profile text, memories, and\n // formatting) across all hosts, in approximate tokens: ceil(UTF-8 bytes / 4).\n // Covers prompt retrieval, recent-memory injection, and session-memory\n // restoration. Not an exact provider token limit or a spending limit.\n \"retrievalMaxTokens\": 2000,\n\n // ============================================\n // Advanced Settings\n // ============================================\n \n // Inject user profile into AI context (preferences, patterns, workflows)\n \"injectProfile\": true\n}\n";
110
+ 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 embeddingApiUrl (OpenAI-compatible /embeddings); embeddingApiKey is optional.\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 (URL required; key only if the server needs one)\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 // Start a background import of past chats on each host's next start.\n \"autoBackfill\": true,\n // \"piBackfillModel\": \"inherit\", // or \"external\", or \"provider/model\"\n // \"opencodeBackfillModel\": \"inherit\", // or \"external\", or \"provider/model\"\n // Directory maps for history recorded in moved or deleted directories.\n // \"importPathMaps\": [{ \"from\": \"~/code/app-feat-x\", \"to\": \"~/code/app\" }],\n // Unresolved directories to hide on the Directory maps page. They stay unimported.\n // \"importIgnoredDirectories\": [\"/private/tmp/scratch\"],\n // Claude Code's folder, when it is not ~/.claude (like CLAUDE_CONFIG_DIR).\n // \"claudeConfigDir\": \"~/.claude-work\",\n\n // Register the web app to start when you log in.\n \"webServerAutoStart\": true,\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 // Deprecated: imported once into the API token table, then no longer read.\n // Create API tokens on the Settings page (Keys and access) instead.\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, or to \"external\" to send\n // every call to the external API below.\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\" | \"google-gemini\"\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 // Tries for one turn's capture, on Pi and OpenCode, 2 and 4 seconds apart. After the\n // last one fails, a turn the model could not reach goes to the capture retry queue.\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 // Capture retry queue: when a live capture fails because the model cannot be\n // reached (network error, timeout, HTTP 408, 429 or 5xx), OMMS keeps a copy of\n // the turn in ~/.omms/data and tries it again later. The copy has <private>\n // text and common API key formats removed. Turns are deleted when the retry\n // ends or after captureRetryRetentionHours (0 to 720). 0 turns the queue off.\n // Only this global file can set it.\n // \"captureRetryRetentionHours\": 72,\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 // Days before an inactive workflow is eligible for removal. A workflow seen\n // userProfileMinEvidenceForRetention times or more is kept.\n \"userProfileWorkflowStaleDays\": 30,\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 // Manual searches can ask for fewer; prompt retrieval uses this ceiling.\n \"maxMemories\": 10,\n\n // ============================================\n // Memory Context Controls\n // ============================================\n\n // Recent memories added at fresh session start (OpenCode V1 and Claude Code).\n // Pi and OpenCode V2 search the store with each prompt instead.\n \"chatMessage\": {\n \"enabled\": true,\n // Recent memories injected at session start.\n \"maxMemories\": 3,\n \"excludeCurrentSession\": true,\n // When to inject: \"first\" message of a session or \"always\".\n // \"injectOn\": \"first\",\n },\n\n // OpenCode profile-learning input limit, in UTF-8 bytes. Smaller values can\n // omit prompts from that input. Does not limit Pi or Claude Code profile input.\n \"userProfileMaxContextBytes\": 32768,\n\n // Shared budget for automatic memory context (profile text, memories, and\n // formatting) across all hosts, in approximate tokens: ceil(UTF-8 bytes / 4).\n // Covers prompt retrieval, recent-memory injection, and session-memory\n // restoration. Not an exact provider token limit or a spending limit.\n \"retrievalMaxTokens\": 2000,\n\n // ============================================\n // Advanced Settings\n // ============================================\n \n // Inject user profile into AI context (preferences, patterns, workflows)\n \"injectProfile\": true\n}\n";
110
111
  export declare function normalizeAutoCaptureMaxContextBytes(value: number): number;
111
112
  /** Whole hours from 0 to 720. A value that is not a number gives the default. */
112
113
  export declare function normalizeCaptureRetryRetentionHours(value: unknown): number;
@@ -187,6 +188,7 @@ declare function buildConfig(fileConfig: OmmsConfig): {
187
188
  userProfileAutoCleanupInterval: number;
188
189
  userProfileValidationEnabled: boolean;
189
190
  userProfileStaleDays: number;
191
+ userProfileWorkflowStaleDays: number;
190
192
  showAutoCaptureToasts: boolean;
191
193
  showUserProfileToasts: boolean;
192
194
  showErrorToasts: boolean;
@@ -284,6 +286,7 @@ export declare let CONFIG: {
284
286
  userProfileAutoCleanupInterval: number;
285
287
  userProfileValidationEnabled: boolean;
286
288
  userProfileStaleDays: number;
289
+ userProfileWorkflowStaleDays: number;
287
290
  showAutoCaptureToasts: boolean;
288
291
  showUserProfileToasts: boolean;
289
292
  showErrorToasts: boolean;
package/dist/config.js CHANGED
@@ -78,6 +78,7 @@ const DEFAULTS = {
78
78
  userProfileDisplayPatterns: 15,
79
79
  userProfileDisplayWorkflows: 10,
80
80
  userProfileStaleDays: 2,
81
+ userProfileWorkflowStaleDays: 30,
81
82
  userProfileInjectPreferences: 5,
82
83
  userProfileInjectPatterns: 5,
83
84
  userProfileInjectWorkflows: 3,
@@ -450,6 +451,10 @@ export const CONFIG_TEMPLATE = `{
450
451
  // Days before inactive items (all types) are eligible for removal
451
452
  "userProfileStaleDays": 2,
452
453
 
454
+ // Days before an inactive workflow is eligible for removal. A workflow seen
455
+ // userProfileMinEvidenceForRetention times or more is kept.
456
+ "userProfileWorkflowStaleDays": 30,
457
+
453
458
  // Number of preferences shown in UI
454
459
  "userProfileDisplayPreferences": 20,
455
460
 
@@ -762,6 +767,7 @@ function buildConfig(fileConfig) {
762
767
  userProfileAutoCleanupInterval,
763
768
  userProfileValidationEnabled: fileConfig.userProfileValidationEnabled ?? DEFAULTS.userProfileValidationEnabled,
764
769
  userProfileStaleDays: fileConfig.userProfileStaleDays ?? DEFAULTS.userProfileStaleDays,
770
+ userProfileWorkflowStaleDays: fileConfig.userProfileWorkflowStaleDays ?? DEFAULTS.userProfileWorkflowStaleDays,
765
771
  showAutoCaptureToasts: fileConfig.showAutoCaptureToasts ?? DEFAULTS.showAutoCaptureToasts,
766
772
  showUserProfileToasts: fileConfig.showUserProfileToasts ?? DEFAULTS.showUserProfileToasts,
767
773
  showErrorToasts: fileConfig.showErrorToasts ?? DEFAULTS.showErrorToasts,
@@ -54,6 +54,7 @@ export async function importClaudeHistory(deps, filters) {
54
54
  host: "claude-code",
55
55
  signal: deps.signal,
56
56
  dryRun: Boolean(filters.dryRun),
57
+ force: Boolean(filters.force),
57
58
  model: deps.profile.model,
58
59
  batchSize: deps.profile.batchSize,
59
60
  ...(deps.profile.onProgress ? { onProgress: deps.profile.onProgress } : {}),
@@ -247,7 +247,7 @@ ${source}
247
247
  --skip-memories Record profile prompts only
248
248
  --skip-profile Import memories only
249
249
  --profile-batch <n> Prompts per profile analysis batch (default: 50)
250
- --force Reprocess already-handled memory work units
250
+ --force Reprocess already-handled memory units and profile prompts
251
251
  --help Show this help
252
252
 
253
253
  Values may follow the flag or use --flag=value.`;
@@ -388,6 +388,7 @@ export async function importPiHistory(deps, filters) {
388
388
  host: "pi",
389
389
  signal: deps.signal,
390
390
  dryRun,
391
+ force: Boolean(filters.force),
391
392
  model: deps.profile.model,
392
393
  batchSize: deps.profile.batchSize,
393
394
  ...(deps.profile.onProgress ? { onProgress: deps.profile.onProgress } : {}),
@@ -139,6 +139,7 @@ export async function importOpencodeHistory(options) {
139
139
  host: "opencode",
140
140
  signal: options.signal,
141
141
  dryRun: options.dryRun,
142
+ force: options.force,
142
143
  batchSize: options.profileBatch,
143
144
  model: options.profileModel,
144
145
  ...(options.onProfileProgress ? { onProgress: options.onProfileProgress } : {}),
@@ -12,12 +12,14 @@ export interface ProfileImportReport {
12
12
  remaining: number;
13
13
  error?: string;
14
14
  }
15
- type PromptStore = Pick<typeof userPromptManager, "savePrompt" | "markAsCaptured" | "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured">;
15
+ type PromptStore = Pick<typeof userPromptManager, "savePrompt" | "markAsCaptured" | "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured" | "markForUserLearning">;
16
16
  type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
17
17
  type Ledger = Pick<ImportLedger, "get" | "begin" | "complete"> & Partial<Pick<ImportLedger, "peek">>;
18
18
  export interface ProfileImportOptions {
19
19
  host: MemoryHost;
20
20
  dryRun?: boolean;
21
+ /** Re-analyse prompts the ledger already records as done. */
22
+ force?: boolean;
21
23
  signal?: AbortSignal;
22
24
  batchSize?: number;
23
25
  model?: ModelPort;
@@ -46,12 +46,18 @@ export async function importProfileFromHistory(source, options) {
46
46
  continue;
47
47
  directory ??= session.directory;
48
48
  const profileKey = `${key}#profile`;
49
- const existing = !ledgerReadable
49
+ // A forced run re-analyses each prompt once; this key records that it did.
50
+ const rebuildKey = `${key}#profile-rebuild`;
51
+ const read = async (ledgerKey) => !ledgerReadable
50
52
  ? null
51
53
  : options.dryRun && ledger.peek
52
- ? await ledger.peek(profileKey)
53
- : await ledger.get(profileKey);
54
- if (existing?.status === "imported") {
54
+ ? await ledger.peek(ledgerKey)
55
+ : await ledger.get(ledgerKey);
56
+ const existing = await read(profileKey);
57
+ const reanalyse = existing?.status === "imported" &&
58
+ Boolean(options.force) &&
59
+ (await read(rebuildKey))?.status !== "imported";
60
+ if (existing?.status === "imported" && !reanalyse) {
55
61
  report.promptsAlreadyHandled++;
56
62
  continue;
57
63
  }
@@ -60,7 +66,7 @@ export async function importProfileFromHistory(source, options) {
60
66
  continue;
61
67
  }
62
68
  await ledger.begin({
63
- key: profileKey,
69
+ key: reanalyse ? rebuildKey : profileKey,
64
70
  sessionId: session.sessionId,
65
71
  sourceFile: session.sourceFile,
66
72
  projectHash: "profile",
@@ -68,7 +74,10 @@ export async function importProfileFromHistory(source, options) {
68
74
  // savePrompt also deduplicates a retry after a crash before ledger completion.
69
75
  const promptId = await prompts.savePrompt(session.sessionId, unit.userEntryId, session.directory, prompt);
70
76
  await prompts.markAsCaptured(promptId);
71
- await ledger.complete(profileKey, promptId);
77
+ // A forced unit may find its prompt still stored and already learned.
78
+ if (reanalyse)
79
+ await prompts.markForUserLearning(promptId);
80
+ await ledger.complete(reanalyse ? rebuildKey : profileKey, promptId);
72
81
  report.promptsRecorded++;
73
82
  }
74
83
  }
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { CONFIG } from "../../config.js";
4
5
  /**
@@ -51,6 +52,26 @@ function readLiveLock(path) {
51
52
  function lockFilePath(scope, hash) {
52
53
  return join(CONFIG.storagePath, WRITE_LOCK_DIR, `${scope}_${hash}.lock`);
53
54
  }
55
+ async function publishLock(candidate, path) {
56
+ // Match the bounded Windows hard-link retries used by the web start lock (TDR-034).
57
+ const waits = [1, 2, 5, 10, 20, 50, 100, 200];
58
+ for (let attempt = 0;; attempt++) {
59
+ try {
60
+ linkSync(candidate, path);
61
+ return;
62
+ }
63
+ catch (error) {
64
+ const code = error.code;
65
+ if (process.platform !== "win32" ||
66
+ !code ||
67
+ !["EPERM", "EACCES", "EBUSY"].includes(code) ||
68
+ waits[attempt] === undefined) {
69
+ throw error;
70
+ }
71
+ await new Promise((resolve) => setTimeout(resolve, waits[attempt]));
72
+ }
73
+ }
74
+ }
54
75
  export async function withCrossProcessWriteLock(scope, hash, fn) {
55
76
  const dir = join(CONFIG.storagePath, WRITE_LOCK_DIR);
56
77
  if (!existsSync(dir)) {
@@ -59,31 +80,47 @@ export async function withCrossProcessWriteLock(scope, hash, fn) {
59
80
  const path = lockFilePath(scope, hash);
60
81
  const state = { pid: process.pid, timestamp: new Date().toISOString() };
61
82
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
62
- for (;;) {
63
- try {
64
- writeFileSync(path, JSON.stringify(state), { flag: "wx" });
65
- break;
66
- }
67
- catch {
68
- const holder = readLiveLock(path);
69
- if (!holder)
70
- continue;
71
- if (Date.now() > deadline) {
72
- throw new Error(`Timed out acquiring the cross-process write lock for ${scope}/${hash}: ` +
73
- `held by pid ${holder.pid} since ${holder.timestamp}`);
83
+ const candidate = `${path}.${process.pid}.${randomUUID()}.tmp`;
84
+ let acquired = false;
85
+ try {
86
+ // Exclusive open exposes an empty file before its PID is written. Publish
87
+ // the complete payload with a hard link, which cannot replace a live lock.
88
+ writeFileSync(candidate, JSON.stringify(state), { flag: "wx" });
89
+ for (;;) {
90
+ try {
91
+ await publishLock(candidate, path);
92
+ acquired = true;
93
+ break;
94
+ }
95
+ catch (error) {
96
+ if (error.code !== "EEXIST")
97
+ throw error;
98
+ const holder = readLiveLock(path);
99
+ if (!holder)
100
+ continue;
101
+ if (Date.now() > deadline) {
102
+ throw new Error(`Timed out acquiring the cross-process write lock for ${scope}/${hash}: ` +
103
+ `held by pid ${holder.pid} since ${holder.timestamp}`, { cause: error });
104
+ }
105
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS + Math.random() * 10));
74
106
  }
75
- await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS + Math.random() * 10));
76
107
  }
77
- }
78
- try {
79
108
  return await fn();
80
109
  }
81
110
  finally {
111
+ if (acquired) {
112
+ try {
113
+ unlinkSync(path);
114
+ }
115
+ catch {
116
+ // Best effort: a stale file is cleaned up by the next acquirer.
117
+ }
118
+ }
82
119
  try {
83
- unlinkSync(path);
120
+ unlinkSync(candidate);
84
121
  }
85
122
  catch {
86
- // Best effort: a stale file is cleaned up by the next acquirer.
123
+ // A failed publication must not leave its temporary payload behind.
87
124
  }
88
125
  }
89
126
  }
@@ -348,9 +348,9 @@ export class UserProfileManager {
348
348
  }
349
349
  decayInMemory(data) {
350
350
  const now = Date.now();
351
- const prefResult = this.decayItems(data.preferences, now);
352
- const patResult = this.decayItems(data.patterns, now);
353
- const wfResult = this.decayItems(data.workflows, now);
351
+ const prefResult = this.decayItems(data.preferences, now, "preference");
352
+ const patResult = this.decayItems(data.patterns, now, "pattern");
353
+ const wfResult = this.decayItems(data.workflows, now, "workflow");
354
354
  return {
355
355
  data: {
356
356
  ...data,
@@ -361,7 +361,7 @@ export class UserProfileManager {
361
361
  hasChanges: prefResult.hasChanges || patResult.hasChanges || wfResult.hasChanges,
362
362
  };
363
363
  }
364
- decayItems(items, now) {
364
+ decayItems(items, now, itemType) {
365
365
  const before = items.length;
366
366
  let hasChanges = false;
367
367
  const filtered = items.filter((item) => {
@@ -376,16 +376,25 @@ export class UserProfileManager {
376
376
  hasChanges = true;
377
377
  const age = now - (item.lastSeen || now);
378
378
  const ageDays = age / (24 * 60 * 60 * 1000);
379
- const staleDays = CONFIG.userProfileStaleDays ?? 2;
379
+ const isWorkflow = itemType === "workflow";
380
+ const itemStaleDays = CONFIG.userProfileStaleDays ?? 2;
381
+ // A workflow never decays sooner than other items, even with a long userProfileStaleDays.
382
+ const staleDays = isWorkflow
383
+ ? Math.max(CONFIG.userProfileWorkflowStaleDays ?? 30, itemStaleDays)
384
+ : itemStaleDays;
380
385
  const minEvidence = CONFIG.userProfileMinEvidenceForRetention ?? 3;
381
386
  const evidenceCount = Array.isArray(item.evidence)
382
387
  ? item.evidence.length
383
388
  : 0;
389
+ // The analysis schema gives workflows no evidence, so repeat sightings count instead.
390
+ const support = isWorkflow
391
+ ? Math.max(evidenceCount, item.frequency ?? 1)
392
+ : evidenceCount;
384
393
  // Remove inactive, low-evidence items using configured retention thresholds.
385
- if (ageDays > staleDays && evidenceCount < minEvidence) {
394
+ if (ageDays > staleDays && support < minEvidence) {
386
395
  log("profile decay: removed stale", {
387
396
  cat: item.category || item.description?.substring(0, 30),
388
- evidenceCount,
397
+ evidenceCount: support,
389
398
  minEvidence,
390
399
  ageDays: Math.round(ageDays),
391
400
  staleDays,
@@ -49,6 +49,8 @@ export declare class UserPromptManager {
49
49
  countTrivialPromptsForLearning(): Promise<number>;
50
50
  private waitingTrivialPromptIds;
51
51
  markAsUserLearningCaptured(promptId: string): Promise<void>;
52
+ /** Put a learned prompt back in the profile learning queue. */
53
+ markForUserLearning(promptId: string): Promise<void>;
52
54
  markMultipleAsUserLearningCaptured(promptIds: string[]): Promise<void>;
53
55
  deleteOldPrompts(cutoffTime: number): Promise<{
54
56
  deleted: number;
@@ -246,6 +246,11 @@ export class UserPromptManager {
246
246
  const db = await this.ready();
247
247
  await db.run(`UPDATE user_prompts SET user_learning_captured = 1 WHERE id = ?`, [promptId]);
248
248
  }
249
+ /** Put a learned prompt back in the profile learning queue. */
250
+ async markForUserLearning(promptId) {
251
+ const db = await this.ready();
252
+ await db.run(`UPDATE user_prompts SET user_learning_captured = 0 WHERE id = ?`, [promptId]);
253
+ }
249
254
  async markMultipleAsUserLearningCaptured(promptIds) {
250
255
  if (promptIds.length === 0)
251
256
  return;
@@ -1,4 +1,6 @@
1
1
  export declare const UPDATE_CHECK_INTERVAL_MS: number;
2
+ /** An open page triggers a new check once the last one is this old. */
3
+ export declare const STATUS_RECHECK_AFTER_MS: number;
2
4
  /** An install that takes longer is stopped, and the web app keeps serving. */
3
5
  export declare const INSTALL_TIMEOUT_MS: number;
4
6
  export type UpdateState = "idle" | "installing" | "restarting" | "failed";
@@ -60,11 +62,14 @@ export declare class WebUpdate {
60
62
  private state;
61
63
  private code;
62
64
  private started;
65
+ private lastCheck;
66
+ private checking;
63
67
  constructor(deps: WebUpdateDeps);
64
- /** Check now, then every 6 hours. A second call does nothing. */
68
+ /** Check now, then every 10 minutes. A second call does nothing. */
65
69
  start(): void;
66
- /** Read npm `latest`. A failed read keeps the last result. */
70
+ /** Read npm `latest`. A failed read keeps the last result. Calls during a read share it. */
67
71
  check(): Promise<void>;
72
+ private read;
68
73
  status(): UpdateStatus;
69
74
  private canInstall;
70
75
  /** Start the install and the restart. A request while one runs joins it. */
@@ -1,9 +1,12 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { availableUpdate, latestNpmVersion } from "./update-check.js";
3
- // The web app's npm release check. It reads npm `latest` at start and then every
4
- // 6 hours, and keeps the result in memory for `GET /api/web/status`. It imports
3
+ // The web app's npm release check. It reads npm `latest` at start, every 10
4
+ // minutes, and when an open page reads the status and the last check is over a
5
+ // minute old. It keeps the result in memory for `GET /api/web/status`. It imports
5
6
  // no host adapter and no config, so tests pass every input.
6
- export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
7
+ export const UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
8
+ /** An open page triggers a new check once the last one is this old. */
9
+ export const STATUS_RECHECK_AFTER_MS = 60 * 1000;
7
10
  /** An install that takes longer is stopped, and the web app keeps serving. */
8
11
  export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
9
12
  /** Only a plain release reaches the npm arguments; the value comes from the registry. */
@@ -24,10 +27,12 @@ export class WebUpdate {
24
27
  state = "idle";
25
28
  code = null;
26
29
  started = false;
30
+ lastCheck = -Infinity;
31
+ checking = null;
27
32
  constructor(deps) {
28
33
  this.deps = deps;
29
34
  }
30
- /** Check now, then every 6 hours. A second call does nothing. */
35
+ /** Check now, then every 10 minutes. A second call does nothing. */
31
36
  start() {
32
37
  if (this.started || !this.deps.enabled)
33
38
  return;
@@ -35,10 +40,15 @@ export class WebUpdate {
35
40
  void this.check();
36
41
  this.deps.setInterval(() => void this.check(), UPDATE_CHECK_INTERVAL_MS).unref?.();
37
42
  }
38
- /** Read npm `latest`. A failed read keeps the last result. */
39
- async check() {
43
+ /** Read npm `latest`. A failed read keeps the last result. Calls during a read share it. */
44
+ check() {
40
45
  if (!this.deps.enabled)
41
- return;
46
+ return Promise.resolve();
47
+ this.checking ??= this.read().finally(() => (this.checking = null));
48
+ return this.checking;
49
+ }
50
+ async read() {
51
+ this.lastCheck = this.deps.now();
42
52
  const latest = await latestNpmVersion(this.deps.fetch);
43
53
  if (latest === null) {
44
54
  this.deps.log("Web app update check failed", { code: "unreachable" });
@@ -47,6 +57,9 @@ export class WebUpdate {
47
57
  this.available = availableUpdate(this.deps.version, latest);
48
58
  }
49
59
  status() {
60
+ // An open page polls the status, so a release shows within about a minute.
61
+ if (this.deps.now() - this.lastCheck > STATUS_RECHECK_AFTER_MS)
62
+ void this.check();
50
63
  return {
51
64
  available: this.available,
52
65
  state: this.state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "om-memory-system",
3
- "version": "4.12.0",
3
+ "version": "4.13.3",
4
4
  "description": "Opinionated modular memory system for coding agents (OpenCode + Pi)",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
@@ -36,7 +36,7 @@
36
36
  "web:dev": "cd web && bun run dev",
37
37
  "web:build": "cd web && bun run build",
38
38
  "dev": "tsc --watch",
39
- "test": "bun test",
39
+ "test": "bash scripts/run-tests-isolated.sh",
40
40
  "typecheck": "tsc --noEmit",
41
41
  "lint": "eslint . --max-warnings=0",
42
42
  "format": "prettier --write .",