sessionmem 1.0.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/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/adapters/capabilities/fallbackTools.js +36 -0
- package/dist/adapters/contract/hostAdapterContract.js +1 -0
- package/dist/adapters/factory.js +40 -0
- package/dist/adapters/generic.js +128 -0
- package/dist/adapters/global/antigravity.js +22 -0
- package/dist/adapters/global/claudeCode.js +22 -0
- package/dist/adapters/global/codex.js +22 -0
- package/dist/adapters/global/qcoder.js +22 -0
- package/dist/adapters/ide/cline.js +20 -0
- package/dist/adapters/ide/cursor.js +28 -0
- package/dist/adapters/ide/installer.js +57 -0
- package/dist/adapters/ide/windsurf.js +28 -0
- package/dist/adapters/tools/ping.js +15 -0
- package/dist/cli/commands/config.js +79 -0
- package/dist/cli/commands/export.js +28 -0
- package/dist/cli/commands/forget.js +28 -0
- package/dist/cli/commands/import.js +112 -0
- package/dist/cli/commands/install.js +57 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/ping.js +12 -0
- package/dist/cli/commands/redactScan.js +40 -0
- package/dist/cli/commands/retention.js +54 -0
- package/dist/cli/commands/run.js +26 -0
- package/dist/cli/commands/search.js +29 -0
- package/dist/cli/commands/show.js +15 -0
- package/dist/cli/commands/stats.js +46 -0
- package/dist/cli/commands/sync.js +118 -0
- package/dist/cli/commands/team.js +96 -0
- package/dist/cli/commands/uninstall.js +30 -0
- package/dist/cli/context.js +69 -0
- package/dist/cli/index.js +147 -0
- package/dist/cli/output.js +37 -0
- package/dist/core/api/contracts.js +263 -0
- package/dist/core/api/errors.js +29 -0
- package/dist/core/api/localOnlyPolicy.js +29 -0
- package/dist/core/api/memoryCoreService.js +595 -0
- package/dist/core/api/sessionLifecycleService.js +289 -0
- package/dist/core/config/policyConfig.js +131 -0
- package/dist/core/embed/deterministicEmbed.js +31 -0
- package/dist/core/embed/embeddingVersion.js +1 -0
- package/dist/core/embed/reembedPolicy.js +9 -0
- package/dist/core/embed/textNormalize.js +12 -0
- package/dist/core/injection/formatStartupInjection.js +97 -0
- package/dist/core/injection/tokenBudget.js +38 -0
- package/dist/core/retrieve/decay.js +15 -0
- package/dist/core/retrieve/importance.js +6 -0
- package/dist/core/retrieve/recencyBands.js +18 -0
- package/dist/core/retrieve/retrieveMemories.js +83 -0
- package/dist/core/retrieve/score.js +25 -0
- package/dist/core/schema/migrations/001_initial.sql +25 -0
- package/dist/core/schema/migrations/002_indexes.sql +18 -0
- package/dist/core/schema/migrations/003_summarization_failures.sql +14 -0
- package/dist/core/schema/migrations/004_memory_feedback.sql +12 -0
- package/dist/core/schema/migrations/005_team_provenance.sql +9 -0
- package/dist/core/schema/runMigrations.js +38 -0
- package/dist/core/session.js +4 -0
- package/dist/core/storage/db.js +8 -0
- package/dist/core/storage/memoryFeedbackRepo.js +16 -0
- package/dist/core/storage/memoryRepo.js +179 -0
- package/dist/core/storage/memorySearchRepo.js +30 -0
- package/dist/core/storage/sessionEventsRepo.js +20 -0
- package/dist/core/storage/summarizationFailuresRepo.js +39 -0
- package/dist/core/storage/types.js +1 -0
- package/dist/core/summarize/cloudSummarizer.js +19 -0
- package/dist/core/summarize/localSummarizer.js +31 -0
- package/dist/core/summarize/redaction.js +48 -0
- package/dist/core/summarize/strategySelector.js +7 -0
- package/dist/core/summarize/summaryShape.js +49 -0
- package/package.json +48 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { deterministicEmbed } from "../embed/deterministicEmbed.js";
|
|
2
|
+
import { deleteMemoriesOlderThan as deleteMemoriesOlderThanDefault, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
3
|
+
import { configFilePath, DEFAULT_POLICY_CONFIG, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
4
|
+
import { listSessionEventsBySession } from "../storage/sessionEventsRepo.js";
|
|
5
|
+
import { insertSummarizationFailure } from "../storage/summarizationFailuresRepo.js";
|
|
6
|
+
import { summarizeWithCloud } from "../summarize/cloudSummarizer.js";
|
|
7
|
+
import { summarizeLocalSessionEvents, } from "../summarize/localSummarizer.js";
|
|
8
|
+
import { resolveSummarizerMode } from "../summarize/strategySelector.js";
|
|
9
|
+
const DEFAULT_EMBEDDING_DIMENSION = 32;
|
|
10
|
+
const CLOUD_RETRY_CONFIG = {
|
|
11
|
+
retries: 2,
|
|
12
|
+
};
|
|
13
|
+
const CLOUD_ENABLED_MESSAGE = "Cloud summarization active: allowCloudSummarization=true and ANTHROPIC_API_KEY present";
|
|
14
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
15
|
+
function defaultFailureId() {
|
|
16
|
+
return `sumfail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
17
|
+
}
|
|
18
|
+
function toErrorJson(error) {
|
|
19
|
+
if (error instanceof Error) {
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
message: error.message,
|
|
22
|
+
name: error.name,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify({ message: "Unknown error" });
|
|
26
|
+
}
|
|
27
|
+
async function retryCloud(fn) {
|
|
28
|
+
let attempt = 0;
|
|
29
|
+
let lastError;
|
|
30
|
+
while (attempt <= CLOUD_RETRY_CONFIG.retries) {
|
|
31
|
+
try {
|
|
32
|
+
return await fn();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
lastError = error;
|
|
36
|
+
attempt += 1;
|
|
37
|
+
if (attempt > CLOUD_RETRY_CONFIG.retries) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw lastError;
|
|
43
|
+
}
|
|
44
|
+
function storeSummaryMemory(db, embeddingDimension, input) {
|
|
45
|
+
const embedding = deterministicEmbed(input.summary, embeddingDimension);
|
|
46
|
+
upsertSessionSummaryMemory(db, {
|
|
47
|
+
id: input.memoryId,
|
|
48
|
+
project_id: input.projectId,
|
|
49
|
+
session_id: input.sessionId,
|
|
50
|
+
source_adapter: input.sourceAdapter,
|
|
51
|
+
kind: "summary",
|
|
52
|
+
content: input.summary,
|
|
53
|
+
normalized_content: embedding.normalizedText,
|
|
54
|
+
importance: 7,
|
|
55
|
+
embedding: JSON.stringify(embedding.vector),
|
|
56
|
+
embedding_dim: embedding.dimension,
|
|
57
|
+
embedding_version: embedding.embeddingVersion,
|
|
58
|
+
// Session summaries are locally authored.
|
|
59
|
+
author: input.author,
|
|
60
|
+
origin_project_id: null,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function buildMemoryId(request) {
|
|
64
|
+
return request.memoryId ?? `${request.sessionId}-summary`;
|
|
65
|
+
}
|
|
66
|
+
export function createSessionLifecycleService(deps) {
|
|
67
|
+
const summarizeLocal = deps.summarizeLocal ?? summarizeLocalSessionEvents;
|
|
68
|
+
const summarizeCloud = deps.summarizeCloud ?? summarizeWithCloud;
|
|
69
|
+
const embeddingDimension = deps.embeddingDimension ?? DEFAULT_EMBEDDING_DIMENSION;
|
|
70
|
+
const createFailureId = deps.createFailureId ?? defaultFailureId;
|
|
71
|
+
const author = deps.username ?? "";
|
|
72
|
+
const policyConfigPath = deps.policyConfigPath ?? configFilePath();
|
|
73
|
+
const now = deps.now ?? (() => new Date());
|
|
74
|
+
const deleteOldMemories = deps.deleteOldMemories ?? deleteMemoriesOlderThanDefault;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the effective retentionDays for a session-end prune. An explicit
|
|
77
|
+
* override wins (test seam); otherwise read the validated policy config, which
|
|
78
|
+
* itself falls back to the 90-day default on any failure.
|
|
79
|
+
*/
|
|
80
|
+
function resolveRetentionDays() {
|
|
81
|
+
if (deps.retentionDaysOverride !== undefined) {
|
|
82
|
+
return deps.retentionDaysOverride;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return readPolicyConfig(policyConfigPath).retentionDays;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return DEFAULT_POLICY_CONFIG.retentionDays;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the effective redactionEnabled flag for the session-end
|
|
93
|
+
* auto-summarize redaction step using precedence override (explicit
|
|
94
|
+
* request.config.redactionEnabled) > config.json > default, mirroring
|
|
95
|
+
* resolveRetentionDays. Falls back to the built-in default on any read
|
|
96
|
+
* failure.
|
|
97
|
+
*/
|
|
98
|
+
function resolveRedactionEnabled(explicit) {
|
|
99
|
+
try {
|
|
100
|
+
return resolvePolicySettings({
|
|
101
|
+
override: explicit !== undefined ? { redactionEnabled: explicit } : undefined,
|
|
102
|
+
config: readPolicyConfig(policyConfigPath),
|
|
103
|
+
}).redactionEnabled;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return explicit ?? DEFAULT_POLICY_CONFIG.redactionEnabled;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Light, non-blocking retention prune executed once at session-end.
|
|
111
|
+
* Hard-deletes memories older than the effective retentionDays for this
|
|
112
|
+
* project. retentionDays<=0 disables pruning. Any failure is swallowed
|
|
113
|
+
* so it can never block or fail summarization.
|
|
114
|
+
*/
|
|
115
|
+
function runLightPrune(projectId) {
|
|
116
|
+
try {
|
|
117
|
+
const retentionDays = resolveRetentionDays();
|
|
118
|
+
if (retentionDays <= 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const cutoffMs = now().getTime() - retentionDays * MS_PER_DAY;
|
|
122
|
+
// ISO-8601 UTC with millisecond precision matches the stored created_at
|
|
123
|
+
// format (strftime('%Y-%m-%dT%H:%M:%fZ')) for lexicographic comparison.
|
|
124
|
+
const cutoffIso = new Date(cutoffMs).toISOString();
|
|
125
|
+
deleteOldMemories(deps.db, projectId, cutoffIso);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Light prune is best-effort: never block or fail summarization.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function handleSessionEnd(request) {
|
|
132
|
+
const events = listSessionEventsBySession(deps.db, request.projectId, request.sessionId);
|
|
133
|
+
if (!request.config.autoSummarize) {
|
|
134
|
+
runLightPrune(request.projectId);
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
status: "skipped_disabled",
|
|
138
|
+
usedMode: "local",
|
|
139
|
+
warningCodes: [],
|
|
140
|
+
warningMessages: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (events.length < request.config.minimumEventThreshold) {
|
|
144
|
+
runLightPrune(request.projectId);
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
status: "skipped_threshold",
|
|
148
|
+
usedMode: "local",
|
|
149
|
+
warningCodes: [],
|
|
150
|
+
warningMessages: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const mode = resolveSummarizerMode(request.config);
|
|
154
|
+
const memoryId = buildMemoryId(request);
|
|
155
|
+
const baseInput = {
|
|
156
|
+
events,
|
|
157
|
+
summaryTokenCap: request.config.summaryTokenCap,
|
|
158
|
+
redactionEnabled: resolveRedactionEnabled(request.config.redactionEnabled),
|
|
159
|
+
factMode: request.config.factMode,
|
|
160
|
+
};
|
|
161
|
+
if (mode === "cloud") {
|
|
162
|
+
try {
|
|
163
|
+
const cloudResult = await retryCloud(() => summarizeCloud({
|
|
164
|
+
...baseInput,
|
|
165
|
+
anthropicApiKey: request.config.anthropicApiKey ?? "",
|
|
166
|
+
}));
|
|
167
|
+
storeSummaryMemory(deps.db, embeddingDimension, {
|
|
168
|
+
memoryId,
|
|
169
|
+
projectId: request.projectId,
|
|
170
|
+
sessionId: request.sessionId,
|
|
171
|
+
sourceAdapter: request.sourceAdapter,
|
|
172
|
+
summary: cloudResult.summary,
|
|
173
|
+
author,
|
|
174
|
+
});
|
|
175
|
+
runLightPrune(request.projectId);
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
status: "stored",
|
|
179
|
+
usedMode: "cloud",
|
|
180
|
+
warningCodes: [
|
|
181
|
+
"cloud_summarization_enabled",
|
|
182
|
+
...cloudResult.warningCodes,
|
|
183
|
+
],
|
|
184
|
+
warningMessages: [CLOUD_ENABLED_MESSAGE],
|
|
185
|
+
memoryId,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// fall back to local summarizer
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const fallbackResult = await summarizeLocal(baseInput);
|
|
193
|
+
storeSummaryMemory(deps.db, embeddingDimension, {
|
|
194
|
+
memoryId,
|
|
195
|
+
projectId: request.projectId,
|
|
196
|
+
sessionId: request.sessionId,
|
|
197
|
+
sourceAdapter: request.sourceAdapter,
|
|
198
|
+
summary: fallbackResult.summary,
|
|
199
|
+
author,
|
|
200
|
+
});
|
|
201
|
+
runLightPrune(request.projectId);
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
status: "stored",
|
|
205
|
+
usedMode: "local",
|
|
206
|
+
warningCodes: [
|
|
207
|
+
"cloud_summarization_enabled",
|
|
208
|
+
"cloud_fallback_to_local",
|
|
209
|
+
...fallbackResult.warningCodes,
|
|
210
|
+
],
|
|
211
|
+
warningMessages: [
|
|
212
|
+
CLOUD_ENABLED_MESSAGE,
|
|
213
|
+
"Cloud summarization failed; fallback to local summarizer succeeded.",
|
|
214
|
+
],
|
|
215
|
+
memoryId,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (localError) {
|
|
219
|
+
const failureRecordId = createFailureId();
|
|
220
|
+
insertSummarizationFailure(deps.db, {
|
|
221
|
+
id: failureRecordId,
|
|
222
|
+
project_id: request.projectId,
|
|
223
|
+
session_id: request.sessionId,
|
|
224
|
+
source_adapter: request.sourceAdapter,
|
|
225
|
+
reason: "cloud_and_local_failed",
|
|
226
|
+
attempt_count: CLOUD_RETRY_CONFIG.retries + 2,
|
|
227
|
+
last_error_json: toErrorJson(localError),
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
status: "failed",
|
|
232
|
+
usedMode: "local",
|
|
233
|
+
warningCodes: [
|
|
234
|
+
"cloud_summarization_enabled",
|
|
235
|
+
"cloud_fallback_to_local_failed",
|
|
236
|
+
],
|
|
237
|
+
warningMessages: [
|
|
238
|
+
CLOUD_ENABLED_MESSAGE,
|
|
239
|
+
"Cloud summarization failed; local fallback also failed.",
|
|
240
|
+
],
|
|
241
|
+
failureRecordId,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const localResult = await summarizeLocal(baseInput);
|
|
247
|
+
storeSummaryMemory(deps.db, embeddingDimension, {
|
|
248
|
+
memoryId,
|
|
249
|
+
projectId: request.projectId,
|
|
250
|
+
sessionId: request.sessionId,
|
|
251
|
+
sourceAdapter: request.sourceAdapter,
|
|
252
|
+
summary: localResult.summary,
|
|
253
|
+
author,
|
|
254
|
+
});
|
|
255
|
+
runLightPrune(request.projectId);
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
status: "stored",
|
|
259
|
+
usedMode: "local",
|
|
260
|
+
warningCodes: localResult.warningCodes,
|
|
261
|
+
warningMessages: [],
|
|
262
|
+
memoryId,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const failureRecordId = createFailureId();
|
|
267
|
+
insertSummarizationFailure(deps.db, {
|
|
268
|
+
id: failureRecordId,
|
|
269
|
+
project_id: request.projectId,
|
|
270
|
+
session_id: request.sessionId,
|
|
271
|
+
source_adapter: request.sourceAdapter,
|
|
272
|
+
reason: "local_failed",
|
|
273
|
+
attempt_count: 1,
|
|
274
|
+
last_error_json: toErrorJson(error),
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
status: "failed",
|
|
279
|
+
usedMode: "local",
|
|
280
|
+
warningCodes: ["local_summarization_failed"],
|
|
281
|
+
warningMessages: [],
|
|
282
|
+
failureRecordId,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
handleSessionEnd,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
/**
|
|
6
|
+
* Built-in policy defaults. Used whenever the config file is missing, malformed,
|
|
7
|
+
* or fails validation, and as the lowest-precedence source in
|
|
8
|
+
* {@link resolvePolicySettings}.
|
|
9
|
+
*
|
|
10
|
+
* The default retention window is 90 days, and redaction defaults on.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_POLICY_CONFIG = {
|
|
13
|
+
retentionDays: 90,
|
|
14
|
+
redactionEnabled: true,
|
|
15
|
+
team: { enabled: false },
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Strict schema for the nested `team` section. `enabled` toggles team
|
|
19
|
+
* mode; `sharedPath` points at the shared filesystem store the `sync` command
|
|
20
|
+
* reads/writes. `.strict()` rejects unknown keys inside `team` so
|
|
21
|
+
* unrecognized fields can't slip through; `.default()` keeps backward compat
|
|
22
|
+
* for configs predating team mode (the section materializes as
|
|
23
|
+
* `{ enabled: false }`).
|
|
24
|
+
*/
|
|
25
|
+
const teamConfigShape = z
|
|
26
|
+
.object({
|
|
27
|
+
enabled: z.boolean().default(false),
|
|
28
|
+
sharedPath: z.string().optional(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
/**
|
|
32
|
+
* Field definitions shared between the read and write schemas. `.default()`
|
|
33
|
+
* mirrors the pattern in src/core/api/contracts.ts so missing fields fall back to
|
|
34
|
+
* built-in defaults.
|
|
35
|
+
*/
|
|
36
|
+
const policyConfigShape = {
|
|
37
|
+
retentionDays: z.number().int().default(DEFAULT_POLICY_CONFIG.retentionDays),
|
|
38
|
+
redactionEnabled: z.boolean().default(DEFAULT_POLICY_CONFIG.redactionEnabled),
|
|
39
|
+
team: teamConfigShape.default({ enabled: false }),
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Strict schema for `~/.sessionmem/config.json`. `.strict()` rejects unknown keys
|
|
43
|
+
* so caller-supplied writes cannot set unvalidated settings.
|
|
44
|
+
*/
|
|
45
|
+
export const policyConfigSchema = z.object(policyConfigShape).strict();
|
|
46
|
+
/**
|
|
47
|
+
* Read schema. Unlike the write path, reading from disk STRIPS unknown keys
|
|
48
|
+
* rather than rejecting the whole file. This keeps known-good values when a
|
|
49
|
+
* config written by a newer binary carries fields this version doesn't know,
|
|
50
|
+
* while still discarding unvalidated keys. Type-invalid known fields still
|
|
51
|
+
* throw and trigger the safe-default fallback.
|
|
52
|
+
*/
|
|
53
|
+
const policyConfigReadSchema = z.object(policyConfigShape).strip();
|
|
54
|
+
/**
|
|
55
|
+
* Canonical config-file location, mirroring the `~/.sessionmem` dir convention in
|
|
56
|
+
* src/cli/context.ts.
|
|
57
|
+
*/
|
|
58
|
+
export function configFilePath() {
|
|
59
|
+
return join(homedir(), ".sessionmem", "config.json");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read and validate the policy config from disk.
|
|
63
|
+
*
|
|
64
|
+
* Defaults safely (like localOnlyPolicy.ts): any failure — missing file,
|
|
65
|
+
* unreadable file, malformed JSON, or schema-invalid contents — returns
|
|
66
|
+
* {@link DEFAULT_POLICY_CONFIG} without throwing. Stored values
|
|
67
|
+
* are merged over defaults via the schema's per-field `.default()`.
|
|
68
|
+
*/
|
|
69
|
+
export function readPolicyConfig(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = readFileSync(filePath, "utf8");
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
return policyConfigReadSchema.parse(parsed);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { ...DEFAULT_POLICY_CONFIG };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Persist a partial policy config, merged over the current on-disk values (or
|
|
81
|
+
* defaults when none exist). Creates parent directories as needed and writes
|
|
82
|
+
* pretty-printed JSON.
|
|
83
|
+
*
|
|
84
|
+
* The partial is validated against the strict schema, so unknown keys are
|
|
85
|
+
* rejected (throws) rather than silently written.
|
|
86
|
+
*/
|
|
87
|
+
export function writePolicyConfig(filePath, partial) {
|
|
88
|
+
// Validate the caller-supplied partial first so unknown keys are rejected
|
|
89
|
+
// before we touch the filesystem. `.partial()` keeps `.strict()` semantics.
|
|
90
|
+
const validatedPartial = policyConfigSchema.partial().parse(partial);
|
|
91
|
+
const current = readPolicyConfig(filePath);
|
|
92
|
+
const merged = policyConfigSchema.parse({ ...current, ...validatedPartial });
|
|
93
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
94
|
+
writeFileSync(filePath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve effective FLAT settings using precedence override > config.json >
|
|
99
|
+
* default. Each scalar setting is resolved independently.
|
|
100
|
+
*
|
|
101
|
+
* RESEARCH Pitfall 5: this loop assumes flat scalars. The nested `team` object
|
|
102
|
+
* is deliberately NOT routed through here — use {@link resolveTeamConfig} for it.
|
|
103
|
+
*/
|
|
104
|
+
export function resolvePolicySettings(input) {
|
|
105
|
+
const resolve = (key) => {
|
|
106
|
+
const fromOverride = input.override?.[key];
|
|
107
|
+
if (fromOverride !== undefined)
|
|
108
|
+
return fromOverride;
|
|
109
|
+
const fromConfig = input.config?.[key];
|
|
110
|
+
if (fromConfig !== undefined)
|
|
111
|
+
return fromConfig;
|
|
112
|
+
return DEFAULT_POLICY_CONFIG[key];
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
retentionDays: resolve("retentionDays"),
|
|
116
|
+
redactionEnabled: resolve("redactionEnabled"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the `team` config as a single object unit using precedence
|
|
121
|
+
* override > config.json > default (RESEARCH Pitfall 5). Unlike
|
|
122
|
+
* {@link resolvePolicySettings}, `team` is resolved whole — it is an object, not
|
|
123
|
+
* a flat scalar, so it must never be fed through the per-key scalar loop.
|
|
124
|
+
*/
|
|
125
|
+
export function resolveTeamConfig(input) {
|
|
126
|
+
if (input.override !== undefined)
|
|
127
|
+
return input.override;
|
|
128
|
+
if (input.config !== undefined)
|
|
129
|
+
return input.config;
|
|
130
|
+
return { ...DEFAULT_POLICY_CONFIG.team };
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EMBEDDING_VERSION } from "./embeddingVersion.js";
|
|
3
|
+
import { normalizeEmbeddingText } from "./textNormalize.js";
|
|
4
|
+
function nextHash(seed, blockIndex) {
|
|
5
|
+
return createHash("sha256").update(`${seed}:${blockIndex}`).digest();
|
|
6
|
+
}
|
|
7
|
+
export function deterministicEmbed(input, dimension) {
|
|
8
|
+
if (!Number.isInteger(dimension) || dimension <= 0) {
|
|
9
|
+
throw new Error("dimension must be a positive integer");
|
|
10
|
+
}
|
|
11
|
+
const normalizedText = normalizeEmbeddingText(input);
|
|
12
|
+
const vector = [];
|
|
13
|
+
let blockIndex = 0;
|
|
14
|
+
while (vector.length < dimension) {
|
|
15
|
+
const bytes = nextHash(normalizedText, blockIndex);
|
|
16
|
+
blockIndex += 1;
|
|
17
|
+
for (const byte of bytes) {
|
|
18
|
+
const value = (byte / 255) * 2 - 1;
|
|
19
|
+
vector.push(value);
|
|
20
|
+
if (vector.length === dimension) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
vector,
|
|
27
|
+
normalizedText,
|
|
28
|
+
dimension,
|
|
29
|
+
embeddingVersion: EMBEDDING_VERSION,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EMBEDDING_VERSION = "v1-hash-local";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const INTERNAL_WHITESPACE = /\s+/g;
|
|
2
|
+
/**
|
|
3
|
+
* Normalize text before deterministic embedding generation.
|
|
4
|
+
* Order is fixed by plan contract: trim -> collapse whitespace -> NFKC -> lowercase.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeEmbeddingText(text) {
|
|
7
|
+
return text
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(INTERNAL_WHITESPACE, " ")
|
|
10
|
+
.normalize("NFKC")
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { countTokens, trimLowestPriorityContent } from "./tokenBudget.js";
|
|
2
|
+
const DEFAULT_TOKEN_CAP = 450;
|
|
3
|
+
const HEADER = "Relevant prior context";
|
|
4
|
+
const KIND_ORDER = ["warning", "decision", "fact", "summary", "preference"];
|
|
5
|
+
const KIND_RANK = new Map(KIND_ORDER.map((kind, index) => [kind, index]));
|
|
6
|
+
function kindRank(kind) {
|
|
7
|
+
return KIND_RANK.get(kind) ?? KIND_ORDER.length;
|
|
8
|
+
}
|
|
9
|
+
function isCriticalWarning(memory) {
|
|
10
|
+
return memory.kind === "warning" && memory.importance >= 9;
|
|
11
|
+
}
|
|
12
|
+
function sortMemories(memories) {
|
|
13
|
+
return [...memories].sort((left, right) => {
|
|
14
|
+
const leftRank = kindRank(left.kind);
|
|
15
|
+
const rightRank = kindRank(right.kind);
|
|
16
|
+
if (leftRank !== rightRank) {
|
|
17
|
+
return leftRank - rightRank;
|
|
18
|
+
}
|
|
19
|
+
if (leftRank === KIND_ORDER.length && left.kind !== right.kind) {
|
|
20
|
+
return left.kind.localeCompare(right.kind);
|
|
21
|
+
}
|
|
22
|
+
if (right.score.total !== left.score.total) {
|
|
23
|
+
return right.score.total - left.score.total;
|
|
24
|
+
}
|
|
25
|
+
if (right.updated_at !== left.updated_at) {
|
|
26
|
+
return right.updated_at.localeCompare(left.updated_at);
|
|
27
|
+
}
|
|
28
|
+
return left.id.localeCompare(right.id);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function formatScore(value) {
|
|
32
|
+
return value.toFixed(3);
|
|
33
|
+
}
|
|
34
|
+
function authorPrefix(memory, localUsername) {
|
|
35
|
+
if (memory.author &&
|
|
36
|
+
localUsername &&
|
|
37
|
+
memory.author !== localUsername) {
|
|
38
|
+
return `${memory.author}: `;
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
function formatLine(entry, localUsername) {
|
|
43
|
+
const { memory } = entry;
|
|
44
|
+
const score = memory.score;
|
|
45
|
+
const prefix = authorPrefix(memory, localUsername);
|
|
46
|
+
return [
|
|
47
|
+
`- [${memory.kind}] ${prefix}${entry.content}`,
|
|
48
|
+
`(score total=${formatScore(score.total)}, semantic=${formatScore(score.raw.semantic)}, recency=${formatScore(score.raw.recency)}, importance=${formatScore(score.raw.importance)}; source=${memory.source_adapter}; date=${memory.updated_at})`,
|
|
49
|
+
].join(" ");
|
|
50
|
+
}
|
|
51
|
+
function render(entries, localUsername) {
|
|
52
|
+
if (entries.length === 0) {
|
|
53
|
+
return HEADER;
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
HEADER,
|
|
57
|
+
...entries.map((entry) => formatLine(entry, localUsername)),
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
function lowestDroppableIndex(entries) {
|
|
61
|
+
let index = -1;
|
|
62
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
63
|
+
if (entries[i].preserve) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (index === -1 || entries[i].priority < entries[index].priority) {
|
|
67
|
+
index = i;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return index;
|
|
71
|
+
}
|
|
72
|
+
export function formatStartupInjection(rankedMemories, options = {}) {
|
|
73
|
+
const tokenCap = options.tokenCap ?? DEFAULT_TOKEN_CAP;
|
|
74
|
+
const localUsername = options.localUsername;
|
|
75
|
+
let included = sortMemories(rankedMemories).map((memory) => ({
|
|
76
|
+
memory,
|
|
77
|
+
content: memory.content,
|
|
78
|
+
priority: KIND_ORDER.length - kindRank(memory.kind),
|
|
79
|
+
preserve: isCriticalWarning(memory),
|
|
80
|
+
}));
|
|
81
|
+
let output = render(included, localUsername);
|
|
82
|
+
while (included.length > 0 && countTokens(output) > tokenCap) {
|
|
83
|
+
const trimmed = trimLowestPriorityContent(included);
|
|
84
|
+
if (trimmed.some((entry, index) => entry.content !== included[index].content)) {
|
|
85
|
+
included = trimmed;
|
|
86
|
+
output = render(included, localUsername);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const dropIndex = lowestDroppableIndex(included);
|
|
90
|
+
if (dropIndex === -1) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
included = included.filter((_, index) => index !== dropIndex);
|
|
94
|
+
output = render(included, localUsername);
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getEncoding } from "js-tiktoken";
|
|
2
|
+
const encoding = getEncoding("o200k_base");
|
|
3
|
+
const DEFAULT_MIN_CONTENT_TOKENS = 12;
|
|
4
|
+
const DEFAULT_TRIM_RATIO = 0.75;
|
|
5
|
+
export function countTokens(text) {
|
|
6
|
+
return encoding.encode(text).length;
|
|
7
|
+
}
|
|
8
|
+
export function trimLowestPriorityContent(included, options = {}) {
|
|
9
|
+
const minContentTokens = options.minContentTokens ?? DEFAULT_MIN_CONTENT_TOKENS;
|
|
10
|
+
const trimRatio = options.trimRatio ?? DEFAULT_TRIM_RATIO;
|
|
11
|
+
const ordered = [...included].sort((left, right) => {
|
|
12
|
+
if (left.preserve !== right.preserve) {
|
|
13
|
+
return left.preserve ? 1 : -1;
|
|
14
|
+
}
|
|
15
|
+
if (left.priority !== right.priority) {
|
|
16
|
+
return left.priority - right.priority;
|
|
17
|
+
}
|
|
18
|
+
return countTokens(right.content) - countTokens(left.content);
|
|
19
|
+
});
|
|
20
|
+
const target = ordered.find((entry) => {
|
|
21
|
+
return !entry.preserve && countTokens(entry.content) > minContentTokens;
|
|
22
|
+
});
|
|
23
|
+
if (!target) {
|
|
24
|
+
return included;
|
|
25
|
+
}
|
|
26
|
+
return included.map((entry) => {
|
|
27
|
+
if (entry !== target) {
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
const tokens = encoding.encode(entry.content);
|
|
31
|
+
const keepCount = Math.max(minContentTokens, Math.floor(tokens.length * trimRatio));
|
|
32
|
+
const trimmed = encoding.decode(tokens.slice(0, keepCount)).trimEnd();
|
|
33
|
+
return {
|
|
34
|
+
...entry,
|
|
35
|
+
content: `${trimmed}...`,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const DEFAULT_DECAY_THRESHOLD_DAYS = 7;
|
|
2
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
export function decayOldBoosts(memories, now = new Date(), thresholdDays = DEFAULT_DECAY_THRESHOLD_DAYS) {
|
|
4
|
+
return memories.map((memory) => {
|
|
5
|
+
const updatedAt = new Date(memory.updated_at);
|
|
6
|
+
const ageDays = Math.max(0, (now.getTime() - updatedAt.getTime()) / DAY_IN_MS);
|
|
7
|
+
const shouldDecay = Number.isFinite(ageDays) && ageDays > thresholdDays;
|
|
8
|
+
return {
|
|
9
|
+
...memory,
|
|
10
|
+
decayedImportance: shouldDecay
|
|
11
|
+
? Math.max(1, memory.importance - 1)
|
|
12
|
+
: memory.importance,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
function toDate(value) {
|
|
3
|
+
return value instanceof Date ? value : new Date(value);
|
|
4
|
+
}
|
|
5
|
+
export function getRecencyBandScore(updatedAt, now = new Date()) {
|
|
6
|
+
const updatedDate = toDate(updatedAt);
|
|
7
|
+
const ageDays = Math.max(0, (now.getTime() - updatedDate.getTime()) / DAY_IN_MS);
|
|
8
|
+
if (ageDays <= 1) {
|
|
9
|
+
return 1.0;
|
|
10
|
+
}
|
|
11
|
+
if (ageDays <= 7) {
|
|
12
|
+
return 0.75;
|
|
13
|
+
}
|
|
14
|
+
if (ageDays <= 30) {
|
|
15
|
+
return 0.5;
|
|
16
|
+
}
|
|
17
|
+
return 0.25;
|
|
18
|
+
}
|