om-memory-system 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/adapters/claude-code/hook-client.d.ts +1 -1
- package/dist/adapters/claude-code/hook-client.js +15 -2
- package/dist/adapters/opencode/in-process-client.d.ts +2 -0
- package/dist/adapters/opencode/in-process-client.js +16 -0
- package/dist/adapters/opencode/opencode-host-config.d.ts +3 -0
- package/dist/adapters/opencode/opencode-host-config.js +2 -0
- package/dist/adapters/opencode/tui-recall.d.ts +68 -0
- package/dist/adapters/opencode/tui-recall.js +312 -0
- package/dist/adapters/pi/extension.d.ts +1 -0
- package/dist/adapters/pi/extension.js +28 -11
- package/dist/adapters/pi/recall-rows.d.ts +31 -0
- package/dist/adapters/pi/recall-rows.js +148 -0
- package/dist/adapters/pi/session-context.d.ts +22 -0
- package/dist/adapters/pi/session-context.js +125 -0
- package/dist/config-template.d.ts +1 -1
- package/dist/config-template.js +20 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +8 -4
- package/dist/core/context-budget.d.ts +2 -0
- package/dist/core/context-budget.js +2 -1
- package/dist/core/opening-context.d.ts +45 -0
- package/dist/core/opening-context.js +60 -0
- package/dist/core/recall-summary.d.ts +32 -0
- package/dist/core/recall-summary.js +77 -0
- package/dist/core/retrieval.d.ts +16 -0
- package/dist/core/retrieval.js +33 -11
- package/dist/importer/claude-hook-api.d.ts +5 -4
- package/dist/importer/claude-hook-api.js +58 -41
- package/dist/importer/claude-hook-context.d.ts +49 -0
- package/dist/importer/claude-hook-context.js +264 -0
- package/dist/index.js +1 -1
- package/dist/services/ai/local-api-url.d.ts +2 -0
- package/dist/services/ai/local-api-url.js +12 -0
- package/dist/services/ai/provider-config.d.ts +2 -0
- package/dist/services/ai/provider-config.js +3 -1
- package/dist/services/ai/providers/google-gemini.js +2 -1
- package/dist/services/ai/providers/openai-responses.js +2 -1
- package/dist/services/chat/chat-runtime.js +5 -1
- package/dist/services/chat/chat-status.js +4 -1
- package/dist/services/global-config-writer.js +39 -14
- package/dist/services/injection-options-config.d.ts +53 -0
- package/dist/services/injection-options-config.js +56 -0
- package/dist/services/settings-snapshot.js +44 -12
- package/dist/services/single-flight.d.ts +2 -0
- package/dist/services/single-flight.js +12 -0
- package/dist/services/visible-recall-config.d.ts +39 -0
- package/dist/services/visible-recall-config.js +28 -0
- package/dist/services/web-server.d.ts +3 -0
- package/dist/services/web-server.js +26 -8
- package/dist/utils/memory-limits.d.ts +2 -1
- package/dist/utils/memory-limits.js +6 -3
- package/dist/v2/adapter.d.ts +2 -0
- package/dist/v2/adapter.js +110 -31
- package/dist/v2/legacy-client.js +2 -1
- package/dist/v2/memory-bridge.d.ts +15 -0
- package/dist/v2/memory-bridge.js +21 -0
- package/dist/v2/recall-reporter.d.ts +27 -0
- package/dist/v2/recall-reporter.js +105 -0
- package/dist/v2/recall-rpc.d.ts +51 -0
- package/dist/v2/recall-rpc.js +38 -0
- package/dist/v2/session-parent.d.ts +14 -0
- package/dist/v2/session-parent.js +26 -0
- package/dist/web/assets/{index-CJ_K-hMG.js → index-BhQR88tU.js} +16 -16
- package/dist/web/assets/index-BmFUZbGr.css +2 -0
- package/dist/web/index.html +2 -2
- package/opencode/tui.tsx +22 -3
- package/package.json +1 -1
- package/dist/web/assets/index-ccH97VE2.css +0 -2
|
@@ -5,7 +5,8 @@ import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
|
|
|
5
5
|
import { hasUnexpiredApiToken } from "./api-tokens.js";
|
|
6
6
|
import { resolveClaudeFolder } from "./claude-folder.js";
|
|
7
7
|
import { readGlobalConfigRevision } from "./global-config-writer.js";
|
|
8
|
-
import {
|
|
8
|
+
import { injectionOptionsFromFile } from "./injection-options-config.js";
|
|
9
|
+
import { getMemoryLimitRule, MEMORY_LIMIT_SETTINGS } from "../utils/memory-limits.js";
|
|
9
10
|
import { getAutoCaptureProviderStatus, isExternalModelReady, resolveClaudeCodeLiveModel, resolvePiLiveModel, } from "./ai/live-model-choice.js";
|
|
10
11
|
const editable = [
|
|
11
12
|
"opencodeProvider",
|
|
@@ -37,8 +38,20 @@ const editable = [
|
|
|
37
38
|
"skillLearning",
|
|
38
39
|
"skillLearningRedact",
|
|
39
40
|
"skillFolders",
|
|
41
|
+
"openingContext",
|
|
42
|
+
"openingContextQuery",
|
|
43
|
+
"subagentMemory",
|
|
44
|
+
"compaction.enabled",
|
|
40
45
|
...MEMORY_LIMIT_SETTINGS,
|
|
41
46
|
];
|
|
47
|
+
/**
|
|
48
|
+
* The memory injection settings (change align-recall-host-parity). They are
|
|
49
|
+
* global-only on the page, so each shows the global file's value read with the
|
|
50
|
+
* runtime rules, never the runtime value, which can hold a project's.
|
|
51
|
+
*/
|
|
52
|
+
const injectionKeys = ["openingContext", "openingContextQuery", "subagentMemory"];
|
|
53
|
+
/** `DEFAULTS.compaction.enabled` in src/config.ts. */
|
|
54
|
+
const COMPACTION_ENABLED_DEFAULT = true;
|
|
42
55
|
/** Keys a project config cannot override, so the page always shows the global value. */
|
|
43
56
|
const globalOnly = [
|
|
44
57
|
"captureRetryRetentionHours",
|
|
@@ -77,14 +90,14 @@ function projectFile(directory) {
|
|
|
77
90
|
.find((path) => existsSync(path));
|
|
78
91
|
}
|
|
79
92
|
/**
|
|
80
|
-
* Snapshot entry for a nested
|
|
81
|
-
* merge is shallow, so a project chatMessage object replaces the
|
|
82
|
-
* a missing count means the default even when the global file sets
|
|
83
|
-
* and the source must not be inferred from dotted-key presence alone.
|
|
93
|
+
* Snapshot entry for a nested setting (chatMessage.maxMemories, compaction.*).
|
|
94
|
+
* The runtime merge is shallow, so a project chatMessage object replaces the
|
|
95
|
+
* global one: a missing count means the default even when the global file sets
|
|
96
|
+
* a value, and the source must not be inferred from dotted-key presence alone.
|
|
84
97
|
*/
|
|
85
|
-
function
|
|
86
|
-
const parent =
|
|
87
|
-
const leaf =
|
|
98
|
+
function nestedSetting(path, defaultValue, global, project) {
|
|
99
|
+
const parent = path[0];
|
|
100
|
+
const leaf = path[1];
|
|
88
101
|
const container = (source) => {
|
|
89
102
|
const raw = source[parent];
|
|
90
103
|
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
@@ -97,10 +110,10 @@ function nestedMemorySetting(rule, global, project) {
|
|
|
97
110
|
const hasValue = effective !== undefined && Object.hasOwn(effective, leaf);
|
|
98
111
|
const hasGlobal = globalContainer !== undefined && Object.hasOwn(globalContainer, leaf);
|
|
99
112
|
return {
|
|
100
|
-
value: hasValue ? effective[leaf] :
|
|
113
|
+
value: hasValue ? effective[leaf] : defaultValue,
|
|
101
114
|
source: hasValue ? (projectHasParent ? "project" : "global") : "default",
|
|
102
|
-
globalValue: hasGlobal ? globalContainer[leaf] :
|
|
103
|
-
default:
|
|
115
|
+
globalValue: hasGlobal ? globalContainer[leaf] : defaultValue,
|
|
116
|
+
default: defaultValue,
|
|
104
117
|
// Whether the leaf exists in the global file: the Reset action needs it.
|
|
105
118
|
hasGlobal,
|
|
106
119
|
};
|
|
@@ -111,10 +124,29 @@ export function getSettingsSnapshot(directory) {
|
|
|
111
124
|
// Dotted limit identifiers (chatMessage.maxMemories) are resolved by the
|
|
112
125
|
// nested rule above, so the runtime lookup only handles flat keys.
|
|
113
126
|
const runtime = CONFIG;
|
|
127
|
+
const injection = injectionOptionsFromFile(global);
|
|
128
|
+
const injectionDefaults = injectionOptionsFromFile({});
|
|
114
129
|
const settings = Object.fromEntries(editable.map((key) => {
|
|
115
130
|
const limitRule = getMemoryLimitRule(key);
|
|
116
131
|
if (limitRule && limitRule.path.length > 1) {
|
|
117
|
-
return [key,
|
|
132
|
+
return [key, nestedSetting(limitRule.path, limitRule.default, global, project)];
|
|
133
|
+
}
|
|
134
|
+
if (key === "compaction.enabled") {
|
|
135
|
+
return [key, nestedSetting(key.split("."), COMPACTION_ENABLED_DEFAULT, global, project)];
|
|
136
|
+
}
|
|
137
|
+
const injectionKey = injectionKeys.find((name) => name === key);
|
|
138
|
+
if (injectionKey) {
|
|
139
|
+
const hasGlobal = Object.hasOwn(global, key);
|
|
140
|
+
return [
|
|
141
|
+
key,
|
|
142
|
+
{
|
|
143
|
+
value: injection[injectionKey],
|
|
144
|
+
source: hasGlobal ? "global" : "default",
|
|
145
|
+
globalValue: injection[injectionKey],
|
|
146
|
+
default: injectionDefaults[injectionKey],
|
|
147
|
+
hasGlobal,
|
|
148
|
+
},
|
|
149
|
+
];
|
|
118
150
|
}
|
|
119
151
|
const projectOverrides = Object.hasOwn(project, key) &&
|
|
120
152
|
// A project may turn these off, never on.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Like setInterval, but skips a tick while the previous run of the task is still pending. */
|
|
2
|
+
export function setSingleFlightInterval(task, ms) {
|
|
3
|
+
let running = false;
|
|
4
|
+
return setInterval(() => {
|
|
5
|
+
if (running)
|
|
6
|
+
return;
|
|
7
|
+
running = true;
|
|
8
|
+
void task().finally(() => {
|
|
9
|
+
running = false;
|
|
10
|
+
});
|
|
11
|
+
}, ms);
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `visibleRecall` setting (change add-opencode-v2-visible-recall), kept out
|
|
3
|
+
* of `src/config.ts` to save its lines. Pure on purpose: callers pass the file
|
|
4
|
+
* config and a log function, and the loaded `CONFIG` is never imported here,
|
|
5
|
+
* because many tests stub `src/config.js`.
|
|
6
|
+
*
|
|
7
|
+
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
+
* and only an explicit `false` turns it off. It applies to OpenCode, Pi and
|
|
9
|
+
* Claude Code.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The key as it appears in a config file. `OmmsConfig` in `src/config.ts` has
|
|
13
|
+
* no field for it, to save lines, so the functions take `FileConfigWithKey`,
|
|
14
|
+
* which any config object satisfies. A hand-edited file can hold any value,
|
|
15
|
+
* which is why they compare with `=== false`.
|
|
16
|
+
*/
|
|
17
|
+
export interface VisibleRecallFileKey {
|
|
18
|
+
/**
|
|
19
|
+
* Draw a row in the OpenCode TUI, the Pi terminal and the Claude Code reply for each memory
|
|
20
|
+
* recall. On Claude Code the hook marks each injected section when rows are off, because the
|
|
21
|
+
* recall rows read only the injected text. Default true. A project can only turn it off.
|
|
22
|
+
*/
|
|
23
|
+
visibleRecall?: boolean;
|
|
24
|
+
}
|
|
25
|
+
type FileConfigWithKey = VisibleRecallFileKey & object;
|
|
26
|
+
/** Read the setting from a merged file config. Unset means on. */
|
|
27
|
+
export declare function visibleRecallFromFile(fileConfig: FileConfigWithKey): {
|
|
28
|
+
visibleRecall: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Apply the project rule to `overrides`, the copy of the project config that
|
|
32
|
+
* is merged over the global one. A project may turn the rows off, so a value
|
|
33
|
+
* of `false` stays. Any other project value is dropped, so the global value
|
|
34
|
+
* decides. A project value of `true` is also reported through `log`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyProjectVisibleRecall(project: FileConfigWithKey, overrides: FileConfigWithKey, directory: string, log: (message: string, data: {
|
|
37
|
+
directory: string;
|
|
38
|
+
}) => void): void;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `visibleRecall` setting (change add-opencode-v2-visible-recall), kept out
|
|
3
|
+
* of `src/config.ts` to save its lines. Pure on purpose: callers pass the file
|
|
4
|
+
* config and a log function, and the loaded `CONFIG` is never imported here,
|
|
5
|
+
* because many tests stub `src/config.js`.
|
|
6
|
+
*
|
|
7
|
+
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
+
* and only an explicit `false` turns it off. It applies to OpenCode, Pi and
|
|
9
|
+
* Claude Code.
|
|
10
|
+
*/
|
|
11
|
+
/** Read the setting from a merged file config. Unset means on. */
|
|
12
|
+
export function visibleRecallFromFile(fileConfig) {
|
|
13
|
+
return { visibleRecall: fileConfig.visibleRecall !== false };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Apply the project rule to `overrides`, the copy of the project config that
|
|
17
|
+
* is merged over the global one. A project may turn the rows off, so a value
|
|
18
|
+
* of `false` stays. Any other project value is dropped, so the global value
|
|
19
|
+
* decides. A project value of `true` is also reported through `log`.
|
|
20
|
+
*/
|
|
21
|
+
export function applyProjectVisibleRecall(project, overrides, directory, log) {
|
|
22
|
+
if (project.visibleRecall === false)
|
|
23
|
+
return;
|
|
24
|
+
delete overrides.visibleRecall;
|
|
25
|
+
if (project.visibleRecall === true) {
|
|
26
|
+
log("Project config cannot turn on visibleRecall; the value was ignored", { directory });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -48,6 +48,7 @@ export declare class WebServer {
|
|
|
48
48
|
private holdOffTimer;
|
|
49
49
|
private portsExhaustedNotified;
|
|
50
50
|
private takeoverFailures;
|
|
51
|
+
private lastHealthFailure;
|
|
51
52
|
private readonly maxFallbackPort;
|
|
52
53
|
private settingsImportJobs?;
|
|
53
54
|
private backfillControlsInstance?;
|
|
@@ -99,6 +100,8 @@ export declare class WebServer {
|
|
|
99
100
|
isRunning(): boolean;
|
|
100
101
|
isServerOwner(): boolean;
|
|
101
102
|
getUrl(): string;
|
|
103
|
+
/** Why the last `checkServerAvailable` call said no, or null when it said yes. */
|
|
104
|
+
getLastHealthFailure(): string | null;
|
|
102
105
|
checkServerAvailable(): Promise<boolean>;
|
|
103
106
|
private handleRequest;
|
|
104
107
|
private contentTypeFor;
|
|
@@ -13,10 +13,13 @@ import { AUTH_HEADER, getOrCreateAuthToken, isAuthorizedApiRequest } from "./aut
|
|
|
13
13
|
import { WebAuth } from "./web-auth.js";
|
|
14
14
|
import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
|
|
15
15
|
import { testReranker } from "./reranker-test.js";
|
|
16
|
+
import { setSingleFlightInterval } from "./single-flight.js";
|
|
16
17
|
import { handleListTags, handleListKeywords, handleListMemories, handleAddMemory, handleDeleteMemory, handleBulkDelete, handleUpdateMemory, handleSearch, handleStats, handlePinMemory, handleUnpinMemory, handleRunCleanup, handleRunDeduplication, handleDetectMigration, handleRunMigration, handleDetectTagMigration, handleRunTagMigrationBatch, handleGetTagMigrationProgress, handleDeletePrompt, handleBulkDeletePrompts, handleGetUserProfile, handleGetProfileChangelog, handleGetProfileSnapshot, handleRefreshProfile, handleUpdateProfileItem, } from "./api-handlers.js";
|
|
17
18
|
import { routeModules } from "./web-route-modules.js";
|
|
18
19
|
const isBun = typeof globalThis.Bun !== "undefined";
|
|
19
20
|
const MIN_FAILED_TAKEOVERS = 3;
|
|
21
|
+
/** A busy owner can answer late. Two seconds was short enough to call a live owner dead. */
|
|
22
|
+
const HEALTH_CHECK_TIMEOUT_MS = 8_000;
|
|
20
23
|
export function attachNodeDisconnectHandlers(req, res, onDisconnect) {
|
|
21
24
|
let disconnected = false;
|
|
22
25
|
const disconnectOnce = () => {
|
|
@@ -175,6 +178,7 @@ export class WebServer {
|
|
|
175
178
|
holdOffTimer = null;
|
|
176
179
|
portsExhaustedNotified = false;
|
|
177
180
|
takeoverFailures = 0;
|
|
181
|
+
lastHealthFailure = null;
|
|
178
182
|
maxFallbackPort;
|
|
179
183
|
settingsImportJobs;
|
|
180
184
|
backfillControlsInstance;
|
|
@@ -584,7 +588,7 @@ export class WebServer {
|
|
|
584
588
|
if (this.healthCheckInterval) {
|
|
585
589
|
return;
|
|
586
590
|
}
|
|
587
|
-
this.healthCheckInterval =
|
|
591
|
+
this.healthCheckInterval = setSingleFlightInterval(() => this.waiterTick(), 5000);
|
|
588
592
|
}
|
|
589
593
|
/** One check of a web app that waits for the port. */
|
|
590
594
|
async waiterTick() {
|
|
@@ -667,6 +671,8 @@ export class WebServer {
|
|
|
667
671
|
log("Web server port held by a non-responsive process; falling back to next port", {
|
|
668
672
|
previousPort: this.config.port,
|
|
669
673
|
newPort: nextPort,
|
|
674
|
+
lastFailure: this.lastHealthFailure,
|
|
675
|
+
healthTimeoutMs: HEALTH_CHECK_TIMEOUT_MS,
|
|
670
676
|
}, "warn");
|
|
671
677
|
this.config.port = nextPort;
|
|
672
678
|
}
|
|
@@ -743,6 +749,10 @@ export class WebServer {
|
|
|
743
749
|
getUrl() {
|
|
744
750
|
return webServerUrl(this.config.host, this.config.port);
|
|
745
751
|
}
|
|
752
|
+
/** Why the last `checkServerAvailable` call said no, or null when it said yes. */
|
|
753
|
+
getLastHealthFailure() {
|
|
754
|
+
return this.lastHealthFailure;
|
|
755
|
+
}
|
|
746
756
|
async checkServerAvailable() {
|
|
747
757
|
try {
|
|
748
758
|
// Only a web app on this machine answers here, so the local token file is enough.
|
|
@@ -751,20 +761,28 @@ export class WebServer {
|
|
|
751
761
|
const response = await fetch(`${this.getUrl()}${endpoint}`, {
|
|
752
762
|
method: "GET",
|
|
753
763
|
headers,
|
|
754
|
-
signal: AbortSignal.timeout(
|
|
764
|
+
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
|
755
765
|
});
|
|
756
|
-
if (!response.ok)
|
|
766
|
+
if (!response.ok) {
|
|
767
|
+
this.lastHealthFailure = `http ${response.status}`;
|
|
757
768
|
return false;
|
|
769
|
+
}
|
|
758
770
|
// Fallback spans 10 neighbor ports; any 2xx from an unrelated local
|
|
759
771
|
// service must not be mistaken for an omms owner. Require the
|
|
760
772
|
// response to carry our API envelope.
|
|
761
773
|
const body = (await response.json());
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
return body.success === true;
|
|
774
|
+
const ok = body.success === true && (endpoint !== "/api/health" || body.status === "ok");
|
|
775
|
+
this.lastHealthFailure = ok ? null : "unexpected response";
|
|
776
|
+
return ok;
|
|
766
777
|
}
|
|
767
|
-
catch {
|
|
778
|
+
catch (error) {
|
|
779
|
+
const name = error instanceof Error ? error.name : "";
|
|
780
|
+
this.lastHealthFailure =
|
|
781
|
+
name === "TimeoutError"
|
|
782
|
+
? "timeout"
|
|
783
|
+
: error instanceof Error
|
|
784
|
+
? error.message
|
|
785
|
+
: String(error);
|
|
768
786
|
return false;
|
|
769
787
|
}
|
|
770
788
|
}
|
|
@@ -21,8 +21,9 @@ export declare const MEMORY_LIMITS: {
|
|
|
21
21
|
readonly userProfileMaxContextBytes: MemoryLimitRule;
|
|
22
22
|
readonly retrievalMaxTokens: MemoryLimitRule;
|
|
23
23
|
readonly similarityThreshold: MemoryLimitRule;
|
|
24
|
+
readonly "compaction.memoryLimit": MemoryLimitRule;
|
|
24
25
|
};
|
|
25
|
-
/** The
|
|
26
|
+
/** The settings identifiers: the six Memory card limits in card order, then the restore limit. */
|
|
26
27
|
export declare const MEMORY_LIMIT_SETTINGS: readonly string[];
|
|
27
28
|
export declare function getMemoryLimitRule(setting: string): MemoryLimitRule | undefined;
|
|
28
29
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared validation rules for the six memory-limit settings
|
|
3
|
-
* (openspec change memory-context-controls)
|
|
2
|
+
* Shared validation rules for the six memory-limit settings of the Memory
|
|
3
|
+
* card (openspec change memory-context-controls), and the restore limit
|
|
4
|
+
* `compaction.memoryLimit`, which the Memory injection section edits
|
|
5
|
+
* (change align-recall-host-parity). Pure functions only: values
|
|
4
6
|
* are passed in, so config.ts, the settings writer, and the settings
|
|
5
7
|
* snapshot share one rule set without importing CONFIG. Do not import
|
|
6
8
|
* CONFIG here: many tests stub src/config.js with a partial module.
|
|
@@ -26,8 +28,9 @@ export const MEMORY_LIMITS = {
|
|
|
26
28
|
userProfileMaxContextBytes: rule("userProfileMaxContextBytes", ["userProfileMaxContextBytes"], 32768, "UTF-8 bytes", "integers from 1024 to 16777216", integerInRange(1024, 16777216)),
|
|
27
29
|
retrievalMaxTokens: rule("retrievalMaxTokens", ["retrievalMaxTokens"], 2000, "approximate tokens", "integers from 256 to 65536", integerInRange(256, 65536)),
|
|
28
30
|
similarityThreshold: rule("similarityThreshold", ["similarityThreshold"], 0.6, "similarity score", "numbers from 0 to 1", decimalFrom0To1, defaultSimilarityThreshold),
|
|
31
|
+
"compaction.memoryLimit": rule("compaction.memoryLimit", ["compaction", "memoryLimit"], 10, "memories", "positive safe integers", isPositiveSafeInteger),
|
|
29
32
|
};
|
|
30
|
-
/** The
|
|
33
|
+
/** The settings identifiers: the six Memory card limits in card order, then the restore limit. */
|
|
31
34
|
export const MEMORY_LIMIT_SETTINGS = Object.keys(MEMORY_LIMITS);
|
|
32
35
|
const RULES = MEMORY_LIMITS;
|
|
33
36
|
export function getMemoryLimitRule(setting) {
|
package/dist/v2/adapter.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ import type { Context } from "@opencode/plugin/promise/plugin";
|
|
|
2
2
|
import type { V2MemoryBridge } from "./memory-bridge.js";
|
|
3
3
|
/** Per-session state is bounded so long-running servers cannot grow it without limit. */
|
|
4
4
|
export declare const MAX_TRACKED_SESSIONS = 256;
|
|
5
|
+
/** Session ids remembered as already seen by this process, for the first-prompt rule. */
|
|
6
|
+
export declare const MAX_SEEN_SESSIONS = 500;
|
|
5
7
|
export declare function registerV2Adapter(ctx: Context, legacy: any, memory: V2MemoryBridge): Promise<() => Promise<void>>;
|
package/dist/v2/adapter.js
CHANGED
|
@@ -4,6 +4,8 @@ import { SKILLS_TOOL_INPUT_SCHEMA } from "../core/skills-tool.js";
|
|
|
4
4
|
import { log } from "../services/logger.js";
|
|
5
5
|
import { utf8ByteLength } from "../utils/context-limit.js";
|
|
6
6
|
import { eventBelongsToLocation, legacyToolResult, toLegacyEvent } from "./legacy-client.js";
|
|
7
|
+
import { createRecallReporter } from "./recall-reporter.js";
|
|
8
|
+
import { createSessionParents } from "./session-parent.js";
|
|
7
9
|
const memoryInput = {
|
|
8
10
|
type: "object",
|
|
9
11
|
properties: {
|
|
@@ -40,14 +42,21 @@ const memoryInput = {
|
|
|
40
42
|
};
|
|
41
43
|
/** Per-session state is bounded so long-running servers cannot grow it without limit. */
|
|
42
44
|
export const MAX_TRACKED_SESSIONS = 256;
|
|
45
|
+
/** Session ids remembered as already seen by this process, for the first-prompt rule. */
|
|
46
|
+
export const MAX_SEEN_SESSIONS = 500;
|
|
43
47
|
/** Unadmitted prompts kept per session before the oldest is dropped. */
|
|
44
48
|
const MAX_PENDING_PROMPTS = 16;
|
|
45
49
|
/** Compaction is restored natively through the `compaction` hook, never via the V1 event path. */
|
|
46
50
|
const COMPACTION_EVENT_TYPES = new Set(["session.compacted", "session.compaction.ended"]);
|
|
47
|
-
|
|
51
|
+
const NO_SECTIONS = {
|
|
52
|
+
promptSection: null,
|
|
53
|
+
openingSection: null,
|
|
54
|
+
openingQuery: null,
|
|
55
|
+
};
|
|
56
|
+
function setBounded(map, key, value, limit = MAX_TRACKED_SESSIONS) {
|
|
48
57
|
map.delete(key);
|
|
49
58
|
map.set(key, value);
|
|
50
|
-
while (map.size >
|
|
59
|
+
while (map.size > limit) {
|
|
51
60
|
const oldest = map.keys().next().value;
|
|
52
61
|
if (oldest === undefined)
|
|
53
62
|
break;
|
|
@@ -70,12 +79,23 @@ function deletedSessionID(event) {
|
|
|
70
79
|
export async function registerV2Adapter(ctx, legacy, memory) {
|
|
71
80
|
const retrievals = new Map();
|
|
72
81
|
const restored = new Map();
|
|
82
|
+
// Sessions this process has seen a prompt or compaction for. The first prompt of a session not
|
|
83
|
+
// in it is a first prompt (new session) or a resume, decided in the context hook.
|
|
84
|
+
const seen = new Map();
|
|
85
|
+
// Per session whose first request is still to come: ids of the prompts this process admitted.
|
|
86
|
+
const firstPending = new Map();
|
|
73
87
|
const messageIDs = new Map();
|
|
74
88
|
// The prompt hook runs before OpenCode admits a prompt, so prompts wait here
|
|
75
89
|
// until their message appears in a model request's history (the context
|
|
76
90
|
// hook), and are only then recorded for auto-capture and profile learning.
|
|
77
91
|
const pendingPrompts = new Map();
|
|
92
|
+
const parents = createSessionParents(ctx, setBounded);
|
|
93
|
+
const recall = await createRecallReporter(ctx, memory, setBounded, parents);
|
|
78
94
|
const forgetSession = (sessionID) => {
|
|
95
|
+
recall.forget(sessionID);
|
|
96
|
+
parents.forget(sessionID);
|
|
97
|
+
seen.delete(sessionID);
|
|
98
|
+
firstPending.delete(sessionID);
|
|
79
99
|
retrievals.delete(sessionID);
|
|
80
100
|
restored.delete(sessionID);
|
|
81
101
|
messageIDs.delete(sessionID);
|
|
@@ -108,11 +128,47 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
108
128
|
// reuses the same section, even if the search finishes after the timeout.
|
|
109
129
|
const settledRetrieval = async (retrieval) => {
|
|
110
130
|
if (!retrieval.settled) {
|
|
111
|
-
const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs,
|
|
131
|
+
const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, NO_SECTIONS);
|
|
112
132
|
retrieval.settled ??= { value };
|
|
113
133
|
}
|
|
114
134
|
return retrieval.settled.value;
|
|
115
135
|
};
|
|
136
|
+
// Load the session's own memories once; the restored section is formatted when a request
|
|
137
|
+
// emits it, against that request's captured budget. Each load gets a new object, which keys
|
|
138
|
+
// its one row.
|
|
139
|
+
const loadRestored = async (sessionID, timeoutMs) => {
|
|
140
|
+
try {
|
|
141
|
+
const loading = memory.restoreSession(sessionID);
|
|
142
|
+
const memories = await (timeoutMs === undefined
|
|
143
|
+
? loading
|
|
144
|
+
: withTimeout(loading, timeoutMs, null));
|
|
145
|
+
if (memories)
|
|
146
|
+
setBounded(restored, sessionID, { memories });
|
|
147
|
+
else
|
|
148
|
+
restored.delete(sessionID);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log("v2 restore: failed to load session memories", { sessionID, error: String(error) }, "error");
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
// The first request of a session's first prompt in this process decides new or resumed:
|
|
155
|
+
// an earlier user message in the history means resumed.
|
|
156
|
+
const decideFirstRequest = async (sessionID, messages, retrieval) => {
|
|
157
|
+
const ours = firstPending.get(sessionID);
|
|
158
|
+
if (!firstPending.delete(sessionID))
|
|
159
|
+
return;
|
|
160
|
+
// History is a user message with an id that none of this process's prompts own.
|
|
161
|
+
const earlier = messages.some((message) => message.role === "user" && typeof message.id === "string" && !ours?.has(message.id));
|
|
162
|
+
if (!earlier)
|
|
163
|
+
return;
|
|
164
|
+
if (retrieval)
|
|
165
|
+
retrieval.resumed = true;
|
|
166
|
+
if (!memory.isCompactionEnabled())
|
|
167
|
+
return;
|
|
168
|
+
const subagent = await withTimeout(parents.isSubagent(sessionID), memory.retrievalTimeoutMs, false);
|
|
169
|
+
if (!subagent)
|
|
170
|
+
await loadRestored(sessionID, memory.retrievalTimeoutMs);
|
|
171
|
+
};
|
|
116
172
|
// Each V1 tool becomes a direct V2 tool, like OpenCode's own read/grep/skill, not a
|
|
117
173
|
// Code Mode catalog entry, so the model can always see and call it (as on V1 and Pi).
|
|
118
174
|
const legacyTool = (name, input) => ({
|
|
@@ -178,15 +234,36 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
178
234
|
].slice(-MAX_PENDING_PROMPTS);
|
|
179
235
|
setBounded(pendingPrompts, sessionID, pending);
|
|
180
236
|
}
|
|
181
|
-
if (!
|
|
237
|
+
if (!text.trim())
|
|
238
|
+
return;
|
|
239
|
+
const first = !seen.has(sessionID);
|
|
240
|
+
if (first) {
|
|
241
|
+
setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
|
|
242
|
+
setBounded(firstPending, sessionID, new Set());
|
|
243
|
+
}
|
|
244
|
+
firstPending.get(sessionID)?.add(event.messageID);
|
|
245
|
+
if (!memory.isInjectionEnabled())
|
|
246
|
+
return;
|
|
247
|
+
// A subagent session gets the prompt recall only when `subagentMemory` is on, and never the
|
|
248
|
+
// opening section. A failed or slow lookup counts as a root session.
|
|
249
|
+
const subagent = await withTimeout(parents.isSubagent(sessionID), memory.retrievalTimeoutMs, false);
|
|
250
|
+
if (subagent && !memory.isSubagentMemoryEnabled())
|
|
182
251
|
return;
|
|
183
252
|
// Start the search now with the entry snapshot, so it overlaps prompt admission.
|
|
184
253
|
// An edit during retrieval must not change this request's allowance.
|
|
185
|
-
const
|
|
254
|
+
const search = async () => first && !subagent
|
|
255
|
+
? memory.retrieveFirstPrompt(text, sessionID, budgetBytes)
|
|
256
|
+
: { ...NO_SECTIONS, promptSection: await memory.retrieve(text, sessionID, budgetBytes) };
|
|
257
|
+
const promise = search().catch((error) => {
|
|
186
258
|
log("v2 prompt: memory retrieval failed", { sessionID, error: String(error) }, "error");
|
|
187
|
-
return
|
|
259
|
+
return NO_SECTIONS;
|
|
260
|
+
});
|
|
261
|
+
setBounded(retrievals, sessionID, {
|
|
262
|
+
promise,
|
|
263
|
+
budgetBytes,
|
|
264
|
+
query: text,
|
|
265
|
+
openingToken: {},
|
|
188
266
|
});
|
|
189
|
-
setBounded(retrievals, sessionID, { promise, budgetBytes });
|
|
190
267
|
});
|
|
191
268
|
await ctx.session.hook("context", async (event) => {
|
|
192
269
|
const sessionID = event.sessionID;
|
|
@@ -194,24 +271,31 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
194
271
|
const budgetBytes = retrieval?.budgetBytes ?? memory.snapshotRequestBudget();
|
|
195
272
|
// Before chat.params, which stores the model on the recorded prompt row.
|
|
196
273
|
await recordAdmittedPrompts(sessionID, event.messages);
|
|
197
|
-
|
|
274
|
+
await decideFirstRequest(sessionID, event.messages, retrieval);
|
|
275
|
+
// The retrieval sections and the restored section share one request budget: the
|
|
198
276
|
// restored section is formatted here, within what the retrieval left.
|
|
199
277
|
let remainingBytes = null;
|
|
200
278
|
if (retrieval) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
279
|
+
const { promptSection, openingSection, openingQuery } = await settledRetrieval(retrieval);
|
|
280
|
+
remainingBytes = retrieval.budgetBytes;
|
|
281
|
+
if (promptSection) {
|
|
282
|
+
event.system.push({ type: "text", text: promptSection });
|
|
283
|
+
recall.record(sessionID, retrieval, "search", retrieval.query, promptSection);
|
|
284
|
+
remainingBytes -= utf8ByteLength(promptSection);
|
|
205
285
|
}
|
|
206
|
-
|
|
207
|
-
|
|
286
|
+
if (openingSection && !retrieval.resumed) {
|
|
287
|
+
event.system.push({ type: "text", text: openingSection });
|
|
288
|
+
recall.record(sessionID, retrieval.openingToken, "opening", openingQuery, openingSection);
|
|
289
|
+
remainingBytes -= utf8ByteLength(openingSection);
|
|
208
290
|
}
|
|
209
291
|
}
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
const section = memory.formatRestoredSession(
|
|
213
|
-
if (section)
|
|
292
|
+
const restoredEntry = restored.get(sessionID);
|
|
293
|
+
if (restoredEntry) {
|
|
294
|
+
const section = memory.formatRestoredSession(restoredEntry.memories, remainingBytes ?? budgetBytes);
|
|
295
|
+
if (section) {
|
|
214
296
|
event.system.push({ type: "text", text: section });
|
|
297
|
+
recall.record(sessionID, restoredEntry, "restore", null, section);
|
|
298
|
+
}
|
|
215
299
|
}
|
|
216
300
|
if (legacy["chat.params"]) {
|
|
217
301
|
await legacy["chat.params"]({
|
|
@@ -222,21 +306,12 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
222
306
|
});
|
|
223
307
|
await ctx.session.hook("compaction", async (event) => {
|
|
224
308
|
memory.snapshotRequestBudget();
|
|
309
|
+
const sessionID = event.sessionID;
|
|
310
|
+
setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
|
|
311
|
+
firstPending.delete(sessionID);
|
|
225
312
|
if (!memory.isCompactionEnabled())
|
|
226
313
|
return;
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
// Load once here; the restored section is formatted when a request
|
|
230
|
-
// emits it, against that request's captured budget.
|
|
231
|
-
const memories = await memory.restoreSession(sessionID);
|
|
232
|
-
if (memories)
|
|
233
|
-
setBounded(restored, sessionID, memories);
|
|
234
|
-
else
|
|
235
|
-
restored.delete(sessionID);
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
log("v2 compaction: failed to load session memories", { sessionID, error: String(error) }, "error");
|
|
239
|
-
}
|
|
314
|
+
await loadRestored(sessionID);
|
|
240
315
|
});
|
|
241
316
|
const controller = new AbortController();
|
|
242
317
|
const watcher = (async () => {
|
|
@@ -266,6 +341,10 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
266
341
|
restored.clear();
|
|
267
342
|
messageIDs.clear();
|
|
268
343
|
pendingPrompts.clear();
|
|
344
|
+
seen.clear();
|
|
345
|
+
firstPending.clear();
|
|
346
|
+
parents.clear();
|
|
347
|
+
recall.clear();
|
|
269
348
|
await legacy.dispose?.();
|
|
270
349
|
};
|
|
271
350
|
}
|
package/dist/v2/legacy-client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { markInProcessClient } from "../adapters/opencode/in-process-client.js";
|
|
3
4
|
function textFromParts(parts = []) {
|
|
4
5
|
return parts
|
|
5
6
|
.filter((part) => part.type === "text")
|
|
@@ -216,7 +217,7 @@ export function createLegacyClient(ctx) {
|
|
|
216
217
|
},
|
|
217
218
|
},
|
|
218
219
|
};
|
|
219
|
-
return client;
|
|
220
|
+
return markInProcessClient(client);
|
|
220
221
|
}
|
|
221
222
|
export function legacyToolResult(value) {
|
|
222
223
|
if (typeof value === "string")
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** The sections of a session's first prompt. `openingQuery` is the query the opening search used. */
|
|
2
|
+
export interface FirstPromptSections {
|
|
3
|
+
promptSection: string | null;
|
|
4
|
+
openingSection: string | null;
|
|
5
|
+
openingQuery: string | null;
|
|
6
|
+
}
|
|
1
7
|
/**
|
|
2
8
|
* The memory operations the native OpenCode v2 hooks need, bound to the
|
|
3
9
|
* plugin's location. Kept separate from the adapter so hook wiring can be
|
|
@@ -8,6 +14,10 @@ export interface V2MemoryBridge {
|
|
|
8
14
|
isConfigured(): boolean;
|
|
9
15
|
isInjectionEnabled(): boolean;
|
|
10
16
|
isCompactionEnabled(): boolean;
|
|
17
|
+
/** Effective `visibleRecall` setting for this project, from a fresh config read. */
|
|
18
|
+
isVisibleRecallEnabled(): boolean;
|
|
19
|
+
/** Effective `subagentMemory` setting, from the config the last budget snapshot read. */
|
|
20
|
+
isSubagentMemoryEnabled(): boolean;
|
|
11
21
|
isInternalPrompt(sessionID: string, text: string): boolean;
|
|
12
22
|
recordPrompt(sessionID: string, messageID: string, text: string): Promise<void>;
|
|
13
23
|
/**
|
|
@@ -18,6 +28,11 @@ export interface V2MemoryBridge {
|
|
|
18
28
|
snapshotRequestBudget(): number;
|
|
19
29
|
/** Wrapped per-prompt retrieval section packed within `budgetBytes`, or null when nothing is relevant. */
|
|
20
30
|
retrieve(prompt: string, sessionID: string, budgetBytes: number): Promise<string | null>;
|
|
31
|
+
/**
|
|
32
|
+
* The first prompt of a main session: the prompt section plus the opening section when the
|
|
33
|
+
* option is on. The prompt section equals what `retrieve` returns for the same budget.
|
|
34
|
+
*/
|
|
35
|
+
retrieveFirstPrompt(prompt: string, sessionID: string, budgetBytes: number): Promise<FirstPromptSections>;
|
|
21
36
|
/** The session's own memories for restore after compaction, loaded once, or null. */
|
|
22
37
|
restoreSession(sessionID: string): Promise<unknown[] | null>;
|
|
23
38
|
/** Cached restored memories formatted within `budgetBytes`, or null when nothing fits. */
|
package/dist/v2/memory-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CONFIG, isConfigured, refreshConfigIfChanged } from "../config.js";
|
|
2
2
|
import { buildRetrievalSection, formatMemoriesForCompaction, retrievalWrapperBytes, wrapRetrievalSection, } from "../core/retrieval.js";
|
|
3
3
|
import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../core/context-budget.js";
|
|
4
|
+
import { buildFirstPromptSections, openingQueryFor } from "../core/opening-context.js";
|
|
4
5
|
import { isInternalPrompt, recordUserPrompt } from "../adapters/opencode/user-prompt.js";
|
|
5
6
|
import { memoryClient } from "../services/client.js";
|
|
6
7
|
import { V2_RETRIEVAL_TIMEOUT_MS } from "../services/request-timeouts.js";
|
|
@@ -11,6 +12,11 @@ export function createV2MemoryBridge(directory) {
|
|
|
11
12
|
isConfigured,
|
|
12
13
|
isInjectionEnabled: () => isConfigured() && Boolean(CONFIG.chatMessage.enabled),
|
|
13
14
|
isCompactionEnabled: () => isConfigured() && Boolean(CONFIG.compaction.enabled),
|
|
15
|
+
isVisibleRecallEnabled() {
|
|
16
|
+
refreshConfigIfChanged(directory);
|
|
17
|
+
return CONFIG.visibleRecall !== false;
|
|
18
|
+
},
|
|
19
|
+
isSubagentMemoryEnabled: () => CONFIG.subagentMemory !== false,
|
|
14
20
|
isInternalPrompt,
|
|
15
21
|
async recordPrompt(sessionID, messageID, text) {
|
|
16
22
|
await recordUserPrompt(sessionID, messageID, directory, text);
|
|
@@ -28,6 +34,21 @@ export function createV2MemoryBridge(directory) {
|
|
|
28
34
|
});
|
|
29
35
|
return section ? wrapRetrievalSection(section) : null;
|
|
30
36
|
},
|
|
37
|
+
async retrieveFirstPrompt(prompt, sessionID, budgetBytes) {
|
|
38
|
+
const openingQuery = openingQueryFor({
|
|
39
|
+
openingContext: CONFIG.openingContext,
|
|
40
|
+
openingContextQuery: CONFIG.openingContextQuery,
|
|
41
|
+
});
|
|
42
|
+
const sections = await buildFirstPromptSections({
|
|
43
|
+
prompt,
|
|
44
|
+
openingQuery,
|
|
45
|
+
directory,
|
|
46
|
+
sessionId: sessionID,
|
|
47
|
+
budget: { maxBytes: budgetBytes, wrapperBytes: retrievalWrapperBytes() },
|
|
48
|
+
openingReserveBytes: 0,
|
|
49
|
+
});
|
|
50
|
+
return { ...sections, openingQuery };
|
|
51
|
+
},
|
|
31
52
|
async restoreSession(sessionID) {
|
|
32
53
|
const tags = getTags(directory);
|
|
33
54
|
const result = await memoryClient.searchMemoriesBySessionID(sessionID, tags.project.tag, CONFIG.compaction.memoryLimit);
|