om-memory-system 5.3.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/tui-recall.js +13 -3
- 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 +18 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +5 -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 +11 -7
- package/dist/core/recall-summary.js +14 -8
- 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/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/visible-recall-config.d.ts +7 -2
- package/dist/services/visible-recall-config.js +2 -1
- 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 +107 -33
- package/dist/v2/memory-bridge.d.ts +13 -0
- package/dist/v2/memory-bridge.js +17 -0
- package/dist/v2/recall-reporter.d.ts +6 -3
- package/dist/v2/recall-reporter.js +4 -27
- 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/package.json +1 -1
- package/dist/web/assets/index-ccH97VE2.css +0 -2
|
@@ -8,6 +8,7 @@ import { isValidClaudeConfigDir } from "./claude-folder.js";
|
|
|
8
8
|
import { missingSkillFolder } from "./skill-learning/skill-config.js";
|
|
9
9
|
import { KEYWORD_MODES } from "../utils/keyword-boost.js";
|
|
10
10
|
import { getMemoryLimitRule } from "../utils/memory-limits.js";
|
|
11
|
+
import { OPENING_CONTEXT_QUERY_MAX_LENGTH } from "./injection-options-config.js";
|
|
11
12
|
import { RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS } from "./request-timeouts.js";
|
|
12
13
|
const keys = new Set([
|
|
13
14
|
"opencodeProvider",
|
|
@@ -47,6 +48,11 @@ const keys = new Set([
|
|
|
47
48
|
"skillLearning",
|
|
48
49
|
"skillLearningRedact",
|
|
49
50
|
"skillFolders",
|
|
51
|
+
"openingContext",
|
|
52
|
+
"openingContextQuery",
|
|
53
|
+
"subagentMemory",
|
|
54
|
+
"compaction.enabled",
|
|
55
|
+
"compaction.memoryLimit",
|
|
50
56
|
]);
|
|
51
57
|
function isFolder(path) {
|
|
52
58
|
try {
|
|
@@ -83,10 +89,18 @@ export function isSecretReference(value) {
|
|
|
83
89
|
/** Reranker keys that a null edit removes, the same wire form as a limit reset. */
|
|
84
90
|
const OPTIONAL_RERANKER_KEYS = ["rerankerApiUrl", "rerankerModel", "rerankerApiKey"];
|
|
85
91
|
/** Other keys whose `null` edit removes them, so they follow the default again. */
|
|
86
|
-
const RESETTABLE_KEYS = [
|
|
92
|
+
const RESETTABLE_KEYS = [
|
|
93
|
+
"keywordMode",
|
|
94
|
+
"skillFolders",
|
|
95
|
+
"openingContextQuery",
|
|
96
|
+
"openingContext",
|
|
97
|
+
"subagentMemory",
|
|
98
|
+
"compaction.enabled",
|
|
99
|
+
];
|
|
100
|
+
const QUERY_RULE = `openingContextQuery must be text of ${OPENING_CONTEXT_QUERY_MAX_LENGTH} characters or fewer`;
|
|
87
101
|
function isValidEdit(key, value) {
|
|
88
102
|
// `undefined` removes the key from the file. `null` is the wire form of
|
|
89
|
-
// "reset to default", accepted for the
|
|
103
|
+
// "reset to default", accepted for the memory limits and the optional reranker keys.
|
|
90
104
|
const memoryLimit = getMemoryLimitRule(key);
|
|
91
105
|
if (memoryLimit && value === null)
|
|
92
106
|
return true;
|
|
@@ -98,6 +112,11 @@ function isValidEdit(key, value) {
|
|
|
98
112
|
// keywordMode: null removes the key, so the mode follows the default again.
|
|
99
113
|
if (key === "keywordMode")
|
|
100
114
|
return value === null || KEYWORD_MODES.includes(value);
|
|
115
|
+
// The opening context query: null removes it; an empty query means no opening search.
|
|
116
|
+
if (key === "openingContextQuery") {
|
|
117
|
+
return (value === null ||
|
|
118
|
+
(typeof value === "string" && value.trim().length <= OPENING_CONTEXT_QUERY_MAX_LENGTH));
|
|
119
|
+
}
|
|
101
120
|
// The five opt-in reranker keys (openspec retrieval-threshold-and-reranker).
|
|
102
121
|
if (key === "rerankerEnabled")
|
|
103
122
|
return typeof value === "boolean";
|
|
@@ -133,8 +152,12 @@ function isValidEdit(key, value) {
|
|
|
133
152
|
key === "webServerAutoStart" ||
|
|
134
153
|
key === "userProfileAutoCleanupEnabled" ||
|
|
135
154
|
key === "skillLearning" ||
|
|
136
|
-
key === "skillLearningRedact"
|
|
137
|
-
|
|
155
|
+
key === "skillLearningRedact" ||
|
|
156
|
+
key === "openingContext" ||
|
|
157
|
+
key === "subagentMemory" ||
|
|
158
|
+
key === "compaction.enabled") {
|
|
159
|
+
// The three memory injection switches take null too: it removes the key (reset to default).
|
|
160
|
+
return typeof value === "boolean" || (value === null && RESETTABLE_KEYS.includes(key));
|
|
138
161
|
}
|
|
139
162
|
// Path rules are checked by the startup validation below; existence just after this check.
|
|
140
163
|
if (key === "skillFolders") {
|
|
@@ -160,8 +183,8 @@ function isValidEdit(key, value) {
|
|
|
160
183
|
// Entries are checked by the startup validation below.
|
|
161
184
|
if (key === "importPathMaps" || key === "importIgnoredDirectories")
|
|
162
185
|
return Array.isArray(value);
|
|
163
|
-
// The
|
|
164
|
-
//
|
|
186
|
+
// The memory limits share one validation rule set (similarityThreshold and
|
|
187
|
+
// compaction.memoryLimit included; null means reset, handled above).
|
|
165
188
|
if (memoryLimit)
|
|
166
189
|
return memoryLimit.isValid(value);
|
|
167
190
|
// An empty value clears the field.
|
|
@@ -218,11 +241,13 @@ options = {}) {
|
|
|
218
241
|
};
|
|
219
242
|
throw new Error(["memoryApiKey", "embeddingApiKey", "webServerAuthPassword", "rerankerApiKey"].includes(key)
|
|
220
243
|
? `${key} must be an env:// or file:// reference`
|
|
221
|
-
:
|
|
222
|
-
?
|
|
223
|
-
:
|
|
224
|
-
? `Invalid ${key} setting: accepted ${
|
|
225
|
-
:
|
|
244
|
+
: key === "openingContextQuery"
|
|
245
|
+
? QUERY_RULE
|
|
246
|
+
: memoryRule
|
|
247
|
+
? `Invalid ${key} setting: accepted ${memoryRule.accepted}`
|
|
248
|
+
: rerankerAccepted[key]
|
|
249
|
+
? `Invalid ${key} setting: accepted ${rerankerAccepted[key]}`
|
|
250
|
+
: `Invalid ${key} setting`);
|
|
226
251
|
}
|
|
227
252
|
const missing = key === "skillFolders" && value !== null
|
|
228
253
|
? missingSkillFolder(value, isFolder)
|
|
@@ -246,9 +271,9 @@ options = {}) {
|
|
|
246
271
|
}
|
|
247
272
|
let content = original.content;
|
|
248
273
|
for (const [key, value] of Object.entries(edits)) {
|
|
249
|
-
// A nested
|
|
250
|
-
//
|
|
251
|
-
const jsoncPath = (getMemoryLimitRule(key)?.path ??
|
|
274
|
+
// A nested identifier maps to its JSONC leaf path, so chatMessage.maxMemories
|
|
275
|
+
// and compaction.enabled write the leaf, never a literal dotted key.
|
|
276
|
+
const jsoncPath = (getMemoryLimitRule(key)?.path ?? key.split("."));
|
|
252
277
|
// null is the wire form of "reset to default": remove the key so the
|
|
253
278
|
// value follows the default again (undefined removes in jsonc modify).
|
|
254
279
|
const jsoncValue = value === null &&
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory injection settings (change align-recall-host-parity): the opening
|
|
3
|
+
* context and subagent memory, on OpenCode V2, Pi and Claude Code. Kept out of
|
|
4
|
+
* `src/config.ts` to save its lines. Pure on purpose, like
|
|
5
|
+
* `visible-recall-config.ts`: callers pass the file config and a log function,
|
|
6
|
+
* and the loaded `CONFIG` is never imported here, because many tests stub
|
|
7
|
+
* `src/config.js`.
|
|
8
|
+
*
|
|
9
|
+
* Read the effective values from `CONFIG.openingContext`,
|
|
10
|
+
* `CONFIG.openingContextQuery` and `CONFIG.subagentMemory`.
|
|
11
|
+
*/
|
|
12
|
+
/** The opening context search query when the config file sets none. */
|
|
13
|
+
export declare const DEFAULT_OPENING_CONTEXT_QUERY = "project architecture, setup, conventions and key decisions";
|
|
14
|
+
/** Most characters of `openingContextQuery`, after trimming. */
|
|
15
|
+
export declare const OPENING_CONTEXT_QUERY_MAX_LENGTH = 500;
|
|
16
|
+
/**
|
|
17
|
+
* The keys as they appear in a config file. `OmmsConfig` in `src/config.ts` has
|
|
18
|
+
* no fields for them, to save lines, so the functions take `FileConfigWithKeys`,
|
|
19
|
+
* which any config object satisfies. A hand-edited file can hold any value,
|
|
20
|
+
* which is why each key is checked for its type.
|
|
21
|
+
*/
|
|
22
|
+
export interface InjectionOptionsFileKeys {
|
|
23
|
+
/** Search memory with `openingContextQuery` on the first message of a new session. Default false. A project can only turn it off. */
|
|
24
|
+
openingContext?: boolean;
|
|
25
|
+
/** The opening context search query. Global only. Trimmed, 500 characters at most. Empty means no search. */
|
|
26
|
+
openingContextQuery?: string;
|
|
27
|
+
/** Give subagent sessions the memories that match their task. Default true. A project can only turn it off. */
|
|
28
|
+
subagentMemory?: boolean;
|
|
29
|
+
}
|
|
30
|
+
type FileConfigWithKeys = InjectionOptionsFileKeys & object;
|
|
31
|
+
/**
|
|
32
|
+
* Read the settings from a merged file config. A value of the wrong type reads
|
|
33
|
+
* as the default. A query over the limit is cut, and `log` gets its length,
|
|
34
|
+
* never its text.
|
|
35
|
+
*/
|
|
36
|
+
export declare function injectionOptionsFromFile(fileConfig: FileConfigWithKeys, log?: (message: string, data: {
|
|
37
|
+
length: number;
|
|
38
|
+
}) => void): {
|
|
39
|
+
openingContext: boolean;
|
|
40
|
+
openingContextQuery: string;
|
|
41
|
+
subagentMemory: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Apply the project rules to `overrides`, the copy of the project config that
|
|
45
|
+
* is merged over the global one. A project may turn either switch off, so a
|
|
46
|
+
* value of `false` stays. Any other value is dropped, and a value of `true` is
|
|
47
|
+
* reported through `log`. The query is global only, so a project query is
|
|
48
|
+
* always dropped and reported.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyProjectInjectionOptions(project: FileConfigWithKeys, overrides: FileConfigWithKeys, directory: string, log: (message: string, data: {
|
|
51
|
+
directory: string;
|
|
52
|
+
}) => void): void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory injection settings (change align-recall-host-parity): the opening
|
|
3
|
+
* context and subagent memory, on OpenCode V2, Pi and Claude Code. Kept out of
|
|
4
|
+
* `src/config.ts` to save its lines. Pure on purpose, like
|
|
5
|
+
* `visible-recall-config.ts`: callers pass the file config and a log function,
|
|
6
|
+
* and the loaded `CONFIG` is never imported here, because many tests stub
|
|
7
|
+
* `src/config.js`.
|
|
8
|
+
*
|
|
9
|
+
* Read the effective values from `CONFIG.openingContext`,
|
|
10
|
+
* `CONFIG.openingContextQuery` and `CONFIG.subagentMemory`.
|
|
11
|
+
*/
|
|
12
|
+
/** The opening context search query when the config file sets none. */
|
|
13
|
+
export const DEFAULT_OPENING_CONTEXT_QUERY = "project architecture, setup, conventions and key decisions";
|
|
14
|
+
/** Most characters of `openingContextQuery`, after trimming. */
|
|
15
|
+
export const OPENING_CONTEXT_QUERY_MAX_LENGTH = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Read the settings from a merged file config. A value of the wrong type reads
|
|
18
|
+
* as the default. A query over the limit is cut, and `log` gets its length,
|
|
19
|
+
* never its text.
|
|
20
|
+
*/
|
|
21
|
+
export function injectionOptionsFromFile(fileConfig, log) {
|
|
22
|
+
const raw = fileConfig.openingContextQuery;
|
|
23
|
+
let query = typeof raw === "string" ? raw.trim() : DEFAULT_OPENING_CONTEXT_QUERY;
|
|
24
|
+
if (query.length > OPENING_CONTEXT_QUERY_MAX_LENGTH) {
|
|
25
|
+
log?.("openingContextQuery is longer than 500 characters; only the first 500 are used", {
|
|
26
|
+
length: query.length,
|
|
27
|
+
});
|
|
28
|
+
query = query.slice(0, OPENING_CONTEXT_QUERY_MAX_LENGTH);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
openingContext: fileConfig.openingContext === true,
|
|
32
|
+
openingContextQuery: query,
|
|
33
|
+
subagentMemory: fileConfig.subagentMemory !== false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Apply the project rules to `overrides`, the copy of the project config that
|
|
38
|
+
* is merged over the global one. A project may turn either switch off, so a
|
|
39
|
+
* value of `false` stays. Any other value is dropped, and a value of `true` is
|
|
40
|
+
* reported through `log`. The query is global only, so a project query is
|
|
41
|
+
* always dropped and reported.
|
|
42
|
+
*/
|
|
43
|
+
export function applyProjectInjectionOptions(project, overrides, directory, log) {
|
|
44
|
+
for (const key of ["openingContext", "subagentMemory"]) {
|
|
45
|
+
if (project[key] === false)
|
|
46
|
+
continue;
|
|
47
|
+
delete overrides[key];
|
|
48
|
+
if (project[key] === true) {
|
|
49
|
+
log(`Project config cannot turn on ${key}; the value was ignored`, { directory });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
delete overrides.openingContextQuery;
|
|
53
|
+
if (Object.hasOwn(project, "openingContextQuery")) {
|
|
54
|
+
log("Project config cannot set openingContextQuery; the value was ignored", { directory });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -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.
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* because many tests stub `src/config.js`.
|
|
6
6
|
*
|
|
7
7
|
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
-
* and only an explicit `false` turns it off.
|
|
8
|
+
* and only an explicit `false` turns it off. It applies to OpenCode, Pi and
|
|
9
|
+
* Claude Code.
|
|
9
10
|
*/
|
|
10
11
|
/**
|
|
11
12
|
* The key as it appears in a config file. `OmmsConfig` in `src/config.ts` has
|
|
@@ -14,7 +15,11 @@
|
|
|
14
15
|
* which is why they compare with `=== false`.
|
|
15
16
|
*/
|
|
16
17
|
export interface VisibleRecallFileKey {
|
|
17
|
-
/**
|
|
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
|
+
*/
|
|
18
23
|
visibleRecall?: boolean;
|
|
19
24
|
}
|
|
20
25
|
type FileConfigWithKey = VisibleRecallFileKey & object;
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* because many tests stub `src/config.js`.
|
|
6
6
|
*
|
|
7
7
|
* Read the effective value from `CONFIG.visibleRecall`. It is on by default,
|
|
8
|
-
* and only an explicit `false` turns it off.
|
|
8
|
+
* and only an explicit `false` turns it off. It applies to OpenCode, Pi and
|
|
9
|
+
* Claude Code.
|
|
9
10
|
*/
|
|
10
11
|
/** Read the setting from a merged file config. Unset means on. */
|
|
11
12
|
export function visibleRecallFromFile(fileConfig) {
|
|
@@ -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
|
@@ -5,6 +5,7 @@ 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
7
|
import { createRecallReporter } from "./recall-reporter.js";
|
|
8
|
+
import { createSessionParents } from "./session-parent.js";
|
|
8
9
|
const memoryInput = {
|
|
9
10
|
type: "object",
|
|
10
11
|
properties: {
|
|
@@ -41,14 +42,21 @@ const memoryInput = {
|
|
|
41
42
|
};
|
|
42
43
|
/** Per-session state is bounded so long-running servers cannot grow it without limit. */
|
|
43
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;
|
|
44
47
|
/** Unadmitted prompts kept per session before the oldest is dropped. */
|
|
45
48
|
const MAX_PENDING_PROMPTS = 16;
|
|
46
49
|
/** Compaction is restored natively through the `compaction` hook, never via the V1 event path. */
|
|
47
50
|
const COMPACTION_EVENT_TYPES = new Set(["session.compacted", "session.compaction.ended"]);
|
|
48
|
-
|
|
51
|
+
const NO_SECTIONS = {
|
|
52
|
+
promptSection: null,
|
|
53
|
+
openingSection: null,
|
|
54
|
+
openingQuery: null,
|
|
55
|
+
};
|
|
56
|
+
function setBounded(map, key, value, limit = MAX_TRACKED_SESSIONS) {
|
|
49
57
|
map.delete(key);
|
|
50
58
|
map.set(key, value);
|
|
51
|
-
while (map.size >
|
|
59
|
+
while (map.size > limit) {
|
|
52
60
|
const oldest = map.keys().next().value;
|
|
53
61
|
if (oldest === undefined)
|
|
54
62
|
break;
|
|
@@ -71,14 +79,23 @@ function deletedSessionID(event) {
|
|
|
71
79
|
export async function registerV2Adapter(ctx, legacy, memory) {
|
|
72
80
|
const retrievals = new Map();
|
|
73
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();
|
|
74
87
|
const messageIDs = new Map();
|
|
75
88
|
// The prompt hook runs before OpenCode admits a prompt, so prompts wait here
|
|
76
89
|
// until their message appears in a model request's history (the context
|
|
77
90
|
// hook), and are only then recorded for auto-capture and profile learning.
|
|
78
91
|
const pendingPrompts = new Map();
|
|
79
|
-
const
|
|
92
|
+
const parents = createSessionParents(ctx, setBounded);
|
|
93
|
+
const recall = await createRecallReporter(ctx, memory, setBounded, parents);
|
|
80
94
|
const forgetSession = (sessionID) => {
|
|
81
95
|
recall.forget(sessionID);
|
|
96
|
+
parents.forget(sessionID);
|
|
97
|
+
seen.delete(sessionID);
|
|
98
|
+
firstPending.delete(sessionID);
|
|
82
99
|
retrievals.delete(sessionID);
|
|
83
100
|
restored.delete(sessionID);
|
|
84
101
|
messageIDs.delete(sessionID);
|
|
@@ -111,11 +128,47 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
111
128
|
// reuses the same section, even if the search finishes after the timeout.
|
|
112
129
|
const settledRetrieval = async (retrieval) => {
|
|
113
130
|
if (!retrieval.settled) {
|
|
114
|
-
const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs,
|
|
131
|
+
const value = await withTimeout(retrieval.promise, memory.retrievalTimeoutMs, NO_SECTIONS);
|
|
115
132
|
retrieval.settled ??= { value };
|
|
116
133
|
}
|
|
117
134
|
return retrieval.settled.value;
|
|
118
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
|
+
};
|
|
119
172
|
// Each V1 tool becomes a direct V2 tool, like OpenCode's own read/grep/skill, not a
|
|
120
173
|
// Code Mode catalog entry, so the model can always see and call it (as on V1 and Pi).
|
|
121
174
|
const legacyTool = (name, input) => ({
|
|
@@ -181,15 +234,36 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
181
234
|
].slice(-MAX_PENDING_PROMPTS);
|
|
182
235
|
setBounded(pendingPrompts, sessionID, pending);
|
|
183
236
|
}
|
|
184
|
-
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())
|
|
185
251
|
return;
|
|
186
252
|
// Start the search now with the entry snapshot, so it overlaps prompt admission.
|
|
187
253
|
// An edit during retrieval must not change this request's allowance.
|
|
188
|
-
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) => {
|
|
189
258
|
log("v2 prompt: memory retrieval failed", { sessionID, error: String(error) }, "error");
|
|
190
|
-
return
|
|
259
|
+
return NO_SECTIONS;
|
|
260
|
+
});
|
|
261
|
+
setBounded(retrievals, sessionID, {
|
|
262
|
+
promise,
|
|
263
|
+
budgetBytes,
|
|
264
|
+
query: text,
|
|
265
|
+
openingToken: {},
|
|
191
266
|
});
|
|
192
|
-
setBounded(retrievals, sessionID, { promise, budgetBytes, query: text });
|
|
193
267
|
});
|
|
194
268
|
await ctx.session.hook("context", async (event) => {
|
|
195
269
|
const sessionID = event.sessionID;
|
|
@@ -197,25 +271,31 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
197
271
|
const budgetBytes = retrieval?.budgetBytes ?? memory.snapshotRequestBudget();
|
|
198
272
|
// Before chat.params, which stores the model on the recorded prompt row.
|
|
199
273
|
await recordAdmittedPrompts(sessionID, event.messages);
|
|
200
|
-
|
|
274
|
+
await decideFirstRequest(sessionID, event.messages, retrieval);
|
|
275
|
+
// The retrieval sections and the restored section share one request budget: the
|
|
201
276
|
// restored section is formatted here, within what the retrieval left.
|
|
202
277
|
let remainingBytes = null;
|
|
203
278
|
if (retrieval) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
209
285
|
}
|
|
210
|
-
|
|
211
|
-
|
|
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);
|
|
212
290
|
}
|
|
213
291
|
}
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
const section = memory.formatRestoredSession(
|
|
217
|
-
if (section)
|
|
292
|
+
const restoredEntry = restored.get(sessionID);
|
|
293
|
+
if (restoredEntry) {
|
|
294
|
+
const section = memory.formatRestoredSession(restoredEntry.memories, remainingBytes ?? budgetBytes);
|
|
295
|
+
if (section) {
|
|
218
296
|
event.system.push({ type: "text", text: section });
|
|
297
|
+
recall.record(sessionID, restoredEntry, "restore", null, section);
|
|
298
|
+
}
|
|
219
299
|
}
|
|
220
300
|
if (legacy["chat.params"]) {
|
|
221
301
|
await legacy["chat.params"]({
|
|
@@ -226,21 +306,12 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
226
306
|
});
|
|
227
307
|
await ctx.session.hook("compaction", async (event) => {
|
|
228
308
|
memory.snapshotRequestBudget();
|
|
309
|
+
const sessionID = event.sessionID;
|
|
310
|
+
setBounded(seen, sessionID, true, MAX_SEEN_SESSIONS);
|
|
311
|
+
firstPending.delete(sessionID);
|
|
229
312
|
if (!memory.isCompactionEnabled())
|
|
230
313
|
return;
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
// Load once here; the restored section is formatted when a request
|
|
234
|
-
// emits it, against that request's captured budget.
|
|
235
|
-
const memories = await memory.restoreSession(sessionID);
|
|
236
|
-
if (memories)
|
|
237
|
-
setBounded(restored, sessionID, memories);
|
|
238
|
-
else
|
|
239
|
-
restored.delete(sessionID);
|
|
240
|
-
}
|
|
241
|
-
catch (error) {
|
|
242
|
-
log("v2 compaction: failed to load session memories", { sessionID, error: String(error) }, "error");
|
|
243
|
-
}
|
|
314
|
+
await loadRestored(sessionID);
|
|
244
315
|
});
|
|
245
316
|
const controller = new AbortController();
|
|
246
317
|
const watcher = (async () => {
|
|
@@ -270,6 +341,9 @@ export async function registerV2Adapter(ctx, legacy, memory) {
|
|
|
270
341
|
restored.clear();
|
|
271
342
|
messageIDs.clear();
|
|
272
343
|
pendingPrompts.clear();
|
|
344
|
+
seen.clear();
|
|
345
|
+
firstPending.clear();
|
|
346
|
+
parents.clear();
|
|
273
347
|
recall.clear();
|
|
274
348
|
await legacy.dispose?.();
|
|
275
349
|
};
|
|
@@ -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
|
|
@@ -10,6 +16,8 @@ export interface V2MemoryBridge {
|
|
|
10
16
|
isCompactionEnabled(): boolean;
|
|
11
17
|
/** Effective `visibleRecall` setting for this project, from a fresh config read. */
|
|
12
18
|
isVisibleRecallEnabled(): boolean;
|
|
19
|
+
/** Effective `subagentMemory` setting, from the config the last budget snapshot read. */
|
|
20
|
+
isSubagentMemoryEnabled(): boolean;
|
|
13
21
|
isInternalPrompt(sessionID: string, text: string): boolean;
|
|
14
22
|
recordPrompt(sessionID: string, messageID: string, text: string): Promise<void>;
|
|
15
23
|
/**
|
|
@@ -20,6 +28,11 @@ export interface V2MemoryBridge {
|
|
|
20
28
|
snapshotRequestBudget(): number;
|
|
21
29
|
/** Wrapped per-prompt retrieval section packed within `budgetBytes`, or null when nothing is relevant. */
|
|
22
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>;
|
|
23
36
|
/** The session's own memories for restore after compaction, loaded once, or null. */
|
|
24
37
|
restoreSession(sessionID: string): Promise<unknown[] | null>;
|
|
25
38
|
/** Cached restored memories formatted within `budgetBytes`, or null when nothing fits. */
|