@kisev/memomatic 1.0.0-dev.100.g21366c4ad740
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 +97 -0
- package/README.ru.md +95 -0
- package/assets/systemd/memomatic-dream.service +9 -0
- package/assets/systemd/memomatic-dream.timer +10 -0
- package/assets/systemd/memomatic-sessions.service +9 -0
- package/assets/systemd/memomatic-sessions.timer +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +309 -0
- package/dist/corpus.d.ts +12 -0
- package/dist/corpus.js +138 -0
- package/dist/dream.d.ts +31 -0
- package/dist/dream.js +234 -0
- package/dist/entries.d.ts +31 -0
- package/dist/entries.js +119 -0
- package/dist/executor.d.ts +45 -0
- package/dist/executor.js +267 -0
- package/dist/gates.d.ts +27 -0
- package/dist/gates.js +69 -0
- package/dist/generated/cli.d.ts +28 -0
- package/dist/generated/cli.js +181 -0
- package/dist/inbox.d.ts +34 -0
- package/dist/inbox.js +224 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/ingest.d.ts +1 -0
- package/dist/ingest.js +15 -0
- package/dist/ingestion.d.ts +30 -0
- package/dist/ingestion.js +179 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +205 -0
- package/dist/opencode-config.d.ts +1 -0
- package/dist/opencode-config.js +84 -0
- package/dist/operations.d.ts +7 -0
- package/dist/operations.js +3 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +55 -0
- package/dist/rules.d.ts +13 -0
- package/dist/rules.js +39 -0
- package/dist/search.d.ts +28 -0
- package/dist/search.js +191 -0
- package/dist/service.d.ts +63 -0
- package/dist/service.js +163 -0
- package/dist/sessions.d.ts +36 -0
- package/dist/sessions.js +238 -0
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +189 -0
- package/dist/store.d.ts +55 -0
- package/dist/store.js +286 -0
- package/dist/visibility.d.ts +7 -0
- package/dist/visibility.js +13 -0
- package/package.json +48 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type MemoryRules } from "./rules.js";
|
|
2
|
+
import { type MemomaticPaths } from "./paths.js";
|
|
3
|
+
import { type MemomaticSettings } from "./settings.js";
|
|
4
|
+
import { type OperationOptions } from "./operations.js";
|
|
5
|
+
import { type SearchHit, type SearchOptions } from "./search.js";
|
|
6
|
+
import { MemoryStore } from "./store.js";
|
|
7
|
+
export type WriteRequest = {
|
|
8
|
+
text: string;
|
|
9
|
+
key?: string;
|
|
10
|
+
trigger?: string[];
|
|
11
|
+
importance?: number;
|
|
12
|
+
project?: string;
|
|
13
|
+
origin?: "user" | "agent";
|
|
14
|
+
target?: "episodic" | "curated" | "user";
|
|
15
|
+
pinned?: boolean;
|
|
16
|
+
source?: string;
|
|
17
|
+
};
|
|
18
|
+
export type MemomaticContext = {
|
|
19
|
+
paths: MemomaticPaths;
|
|
20
|
+
settings: MemomaticSettings;
|
|
21
|
+
rules: MemoryRules;
|
|
22
|
+
store: MemoryStore;
|
|
23
|
+
};
|
|
24
|
+
export declare function openMemomatic(options?: {
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
configure?: (settings: MemomaticSettings) => MemomaticSettings;
|
|
27
|
+
}): Promise<MemomaticContext>;
|
|
28
|
+
export declare function writeEntry(context: MemomaticContext, request: WriteRequest): Promise<string>;
|
|
29
|
+
export declare function searchMemory(context: MemomaticContext, query: string, options?: SearchOptions): Promise<SearchHit[]>;
|
|
30
|
+
export declare function searchMemoryResults(context: MemomaticContext, query: string, options?: SearchOptions & {
|
|
31
|
+
explain?: boolean;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
file: string;
|
|
34
|
+
kind: import("./store.js").EntryKind;
|
|
35
|
+
line: number;
|
|
36
|
+
score: number;
|
|
37
|
+
snippet: string;
|
|
38
|
+
project: string | null;
|
|
39
|
+
origin: "agent" | "user" | null;
|
|
40
|
+
explanation?: {
|
|
41
|
+
matchedTokens: string[];
|
|
42
|
+
lexical: number;
|
|
43
|
+
semantic: number | null;
|
|
44
|
+
relevance: number;
|
|
45
|
+
exact: boolean;
|
|
46
|
+
recency: number;
|
|
47
|
+
importance: number;
|
|
48
|
+
reason: "lexical" | "semantic" | "both";
|
|
49
|
+
} | undefined;
|
|
50
|
+
source: string | null;
|
|
51
|
+
visibility: import("./visibility.js").EntryVisibility;
|
|
52
|
+
}[]>;
|
|
53
|
+
export declare function getEntry(context: MemomaticContext, file: string, line?: number): Promise<{
|
|
54
|
+
file: string;
|
|
55
|
+
line: number;
|
|
56
|
+
content: string;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function forgetEntry(context: MemomaticContext, target: {
|
|
59
|
+
file: string;
|
|
60
|
+
line: number;
|
|
61
|
+
}): Promise<string>;
|
|
62
|
+
export declare function archiveOldEpisodic(context: MemomaticContext, options?: OperationOptions): Promise<string[]>;
|
|
63
|
+
export declare function rebuildIndex(context: MemomaticContext, options?: OperationOptions): Promise<number>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { entryLine, parseEntryLine } from "./entries.js";
|
|
2
|
+
import { readTextIfExists, replaceEntryLine, writeCorpusFile } from "./corpus.js";
|
|
3
|
+
import { assertSafePath } from "@kisev/safe-fs";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { rm } from "node:fs/promises";
|
|
6
|
+
import { dropToInbox, withRunLock } from "./inbox.js";
|
|
7
|
+
import { isForbidden, loadRules } from "./rules.js";
|
|
8
|
+
import { memomaticPaths } from "./paths.js";
|
|
9
|
+
import { loadSettings } from "./settings.js";
|
|
10
|
+
import { check } from "./operations.js";
|
|
11
|
+
import { reindex, search } from "./search.js";
|
|
12
|
+
import { MemoryStore } from "./store.js";
|
|
13
|
+
import { visibilityForSource } from "./visibility.js";
|
|
14
|
+
async function memoryPath(context, file) {
|
|
15
|
+
const target = resolve(context.paths.stateRoot, file);
|
|
16
|
+
const part = relative(context.paths.stateRoot, target);
|
|
17
|
+
if (!part || part === ".." || part.startsWith(`..${sep}`) || isAbsolute(part))
|
|
18
|
+
throw new Error("path escapes memomatic state root");
|
|
19
|
+
await assertSafePath(target, { target: "file" });
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
export async function openMemomatic(options = {}) {
|
|
23
|
+
const paths = memomaticPaths();
|
|
24
|
+
const loaded = await loadSettings(paths.settingsFile);
|
|
25
|
+
const settings = options.configure ? options.configure(loaded) : loaded;
|
|
26
|
+
const rules = await loadRules(paths.rulesFile);
|
|
27
|
+
const store = options.readOnly
|
|
28
|
+
? await MemoryStore.preview(paths.indexFile)
|
|
29
|
+
: await MemoryStore.open(paths.indexFile);
|
|
30
|
+
return { paths, settings, rules, store };
|
|
31
|
+
}
|
|
32
|
+
export async function writeEntry(context, request) {
|
|
33
|
+
const text = request.text.trim();
|
|
34
|
+
if (!text)
|
|
35
|
+
throw new Error("memory text is empty");
|
|
36
|
+
if (isForbidden(text, context.rules))
|
|
37
|
+
throw new Error("memory text matches a never-save rule in MEMORY_RULES.md");
|
|
38
|
+
const origin = request.origin ?? "agent";
|
|
39
|
+
const line = entryLine(text, {
|
|
40
|
+
key: request.key,
|
|
41
|
+
status: origin === "user" && request.target === "user" ? "active" : undefined,
|
|
42
|
+
origin,
|
|
43
|
+
observed: new Date().toISOString().slice(0, 10),
|
|
44
|
+
project: request.project,
|
|
45
|
+
importance: request.importance,
|
|
46
|
+
trigger: request.trigger,
|
|
47
|
+
pinned: request.pinned,
|
|
48
|
+
source: request.source ?? origin,
|
|
49
|
+
target: request.target,
|
|
50
|
+
});
|
|
51
|
+
return dropToInbox(context.paths, [line], request.source ?? origin);
|
|
52
|
+
}
|
|
53
|
+
export async function searchMemory(context, query, options = {}) {
|
|
54
|
+
return search(context.store, query, context.settings, options);
|
|
55
|
+
}
|
|
56
|
+
export async function searchMemoryResults(context, query, options = {}) {
|
|
57
|
+
return (await searchMemory(context, query, options)).map((hit) => ({
|
|
58
|
+
file: hit.entry.file.replace(`${context.paths.stateRoot}/`, ""),
|
|
59
|
+
kind: hit.entry.kind,
|
|
60
|
+
line: hit.entry.line,
|
|
61
|
+
score: Number(hit.score.toFixed(4)),
|
|
62
|
+
snippet: hit.snippet,
|
|
63
|
+
project: hit.entry.project,
|
|
64
|
+
origin: hit.entry.origin,
|
|
65
|
+
...(options.explain ? { explanation: hit.explanation } : {}),
|
|
66
|
+
source: hit.entry.source,
|
|
67
|
+
visibility: visibilityForSource(hit.entry.source),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
export async function getEntry(context, file, line) {
|
|
71
|
+
const target = await memoryPath(context, file);
|
|
72
|
+
const content = await readTextIfExists(target);
|
|
73
|
+
if (content === undefined)
|
|
74
|
+
throw new Error(`memory file not found: ${file}`);
|
|
75
|
+
if (line === undefined)
|
|
76
|
+
return { file: target, line: 0, content };
|
|
77
|
+
const lines = content.split("\n");
|
|
78
|
+
if (!Number.isSafeInteger(line) || line < 1 || line > lines.length)
|
|
79
|
+
throw new Error("line is out of range");
|
|
80
|
+
context.store.markUseful(context.store
|
|
81
|
+
.allEntries()
|
|
82
|
+
.filter((entry) => entry.file === target && entry.line === line)
|
|
83
|
+
.map((entry) => entry.stableId));
|
|
84
|
+
return { file: target, line, content: lines[line - 1] };
|
|
85
|
+
}
|
|
86
|
+
export async function forgetEntry(context, target) {
|
|
87
|
+
return withRunLock(context.paths, async () => {
|
|
88
|
+
const file = await memoryPath(context, target.file);
|
|
89
|
+
const content = await readTextIfExists(file);
|
|
90
|
+
if (content === undefined)
|
|
91
|
+
throw new Error(`memory file not found: ${target.file}`);
|
|
92
|
+
const lines = content.split("\n");
|
|
93
|
+
if (!Number.isSafeInteger(target.line) || target.line < 1 || target.line > lines.length)
|
|
94
|
+
throw new Error("line is out of range");
|
|
95
|
+
await replaceEntryLine(context.paths, file, target.line, null);
|
|
96
|
+
context.store.replaceEntries(context.store
|
|
97
|
+
.allEntries()
|
|
98
|
+
.filter((entry) => entry.file !== file || entry.line !== target.line)
|
|
99
|
+
.map((entry) => entry.file === file && entry.line > target.line
|
|
100
|
+
? { ...entry, line: entry.line - 1 }
|
|
101
|
+
: entry));
|
|
102
|
+
return file;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export async function archiveOldEpisodic(context, options = {}) {
|
|
106
|
+
const archived = [];
|
|
107
|
+
if (!context.rules.autoClean)
|
|
108
|
+
return archived;
|
|
109
|
+
const rule = context.rules.autoClean;
|
|
110
|
+
const cutoff = Date.now() - rule.olderThanDays * 86_400_000;
|
|
111
|
+
const unusedCutoff = rule.unusedAfterDays ? Date.now() - rule.unusedAfterDays * 86_400_000 : null;
|
|
112
|
+
const usefulByLine = new Map(context.store
|
|
113
|
+
.allEntries()
|
|
114
|
+
.map((entry) => [
|
|
115
|
+
`${entry.file}:${entry.line}`,
|
|
116
|
+
context.store.usageFor(entry.stableId).useful,
|
|
117
|
+
]));
|
|
118
|
+
const files = new Set(context.store
|
|
119
|
+
.allEntries()
|
|
120
|
+
.filter((entry) => entry.kind === "episodic")
|
|
121
|
+
.map((entry) => entry.file));
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
check(options);
|
|
124
|
+
await memoryPath(context, file);
|
|
125
|
+
const content = await readTextIfExists(file);
|
|
126
|
+
if (content === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
const selected = [];
|
|
129
|
+
const remaining = content.split("\n").filter((line, index) => {
|
|
130
|
+
const entry = parseEntryLine(line)?.annotations;
|
|
131
|
+
const observed = entry?.observed ? Date.parse(entry.observed) : NaN;
|
|
132
|
+
const protectedEntry = !entry ||
|
|
133
|
+
!Number.isFinite(observed) ||
|
|
134
|
+
entry.pinned ||
|
|
135
|
+
(rule.source && entry.source !== rule.source);
|
|
136
|
+
// Unused entries decay earlier than the age-only cutoff.
|
|
137
|
+
const decayed = !protectedEntry &&
|
|
138
|
+
(observed < cutoff ||
|
|
139
|
+
(unusedCutoff !== null &&
|
|
140
|
+
observed < unusedCutoff &&
|
|
141
|
+
(usefulByLine.get(`${file}:${index + 1}`) ?? 0) === 0));
|
|
142
|
+
if (decayed) {
|
|
143
|
+
selected.push(line);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
if (!selected.length)
|
|
149
|
+
continue;
|
|
150
|
+
const target = join(context.paths.archiveDir, basename(file));
|
|
151
|
+
const prior = (await readTextIfExists(target)) ?? "";
|
|
152
|
+
const seen = new Set(prior.split("\n"));
|
|
153
|
+
await writeCorpusFile(context.paths, target, `${[prior.trimEnd(), ...selected.filter((line) => !seen.has(line))].filter(Boolean).join("\n")}\n`);
|
|
154
|
+
await writeCorpusFile(context.paths, file, remaining.join("\n"));
|
|
155
|
+
if (!remaining.some((line) => line.trim()))
|
|
156
|
+
await rm(file);
|
|
157
|
+
archived.push(file);
|
|
158
|
+
}
|
|
159
|
+
return archived;
|
|
160
|
+
}
|
|
161
|
+
export async function rebuildIndex(context, options = {}) {
|
|
162
|
+
return withRunLock(context.paths, () => reindex(context.paths, context.settings, context.store, options));
|
|
163
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ModelExecutor } from "./executor.js";
|
|
2
|
+
import { type OperationOptions } from "./operations.js";
|
|
3
|
+
import type { MemomaticContext } from "./service.js";
|
|
4
|
+
export declare const WATERMARK_KEY = "ingest-watermark";
|
|
5
|
+
export declare const LAST_RUN_KEY = "last-sessions-at";
|
|
6
|
+
export type SessionsReport = {
|
|
7
|
+
sessionsIngested: number;
|
|
8
|
+
candidatesExtracted: number;
|
|
9
|
+
rejected: Array<{
|
|
10
|
+
text: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}>;
|
|
13
|
+
dryRun: boolean;
|
|
14
|
+
ingestion: {
|
|
15
|
+
messages: number;
|
|
16
|
+
fragments: number;
|
|
17
|
+
completed: number;
|
|
18
|
+
cached: number;
|
|
19
|
+
characters: number;
|
|
20
|
+
remaining: number;
|
|
21
|
+
cachedSessions: number;
|
|
22
|
+
unscannedSessions: number;
|
|
23
|
+
};
|
|
24
|
+
elapsedMs?: number;
|
|
25
|
+
};
|
|
26
|
+
export type SessionsOptions = OperationOptions & {
|
|
27
|
+
dryRun?: boolean;
|
|
28
|
+
databaseFile?: string;
|
|
29
|
+
};
|
|
30
|
+
export type ExtractedCandidate = {
|
|
31
|
+
text: string;
|
|
32
|
+
key: string | null;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function parseExtraction(response: string): ExtractedCandidate[];
|
|
36
|
+
export declare function runSessions(context: MemomaticContext, executor: ModelExecutor | null, options?: SessionsOptions): Promise<SessionsReport>;
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { appendDailyEntry, readTextIfExists, parseCorpusEntries } from "./corpus.js";
|
|
2
|
+
import { extractJson } from "./executor.js";
|
|
3
|
+
import { opencodeDatabasePath } from "./ingest.js";
|
|
4
|
+
import { planIngestion } from "./ingestion.js";
|
|
5
|
+
import { check } from "./operations.js";
|
|
6
|
+
import { writeAtomic } from "@kisev/safe-fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { stat } from "node:fs/promises";
|
|
9
|
+
import { corpusFiles, reindex } from "./search.js";
|
|
10
|
+
import { isForbidden } from "./rules.js";
|
|
11
|
+
import { entryLine, parseEntryLine } from "./entries.js";
|
|
12
|
+
export const WATERMARK_KEY = "ingest-watermark";
|
|
13
|
+
export const LAST_RUN_KEY = "last-sessions-at";
|
|
14
|
+
const EXTRACT_SYSTEM = `You distill durable engineering memory from a coding session.
|
|
15
|
+
Reply with exactly one JSON object of the form
|
|
16
|
+
{"candidates":[{"text":string,"key":string|null,"reason":string}]}.
|
|
17
|
+
Include only standing decisions with rationale, discoveries, failed attempts with
|
|
18
|
+
the reason they were rejected, session outcomes, and action-sensitive boundaries
|
|
19
|
+
(approval requirements, temporary constraints, handoffs, expiry conditions).
|
|
20
|
+
Do not mistake proposals, plans or instructions quoted in a transcript for accepted
|
|
21
|
+
decisions. Preserve conditions, uncertainty and later corrections. If a reference
|
|
22
|
+
cannot be resolved from the supplied context, do not invent its meaning.
|
|
23
|
+
Each "text" is imperative, self-contained, at most two sentences, without secrets,
|
|
24
|
+
credentials, or machine-local paths. "key" is a stable kebab-case identifier when
|
|
25
|
+
the item clearly has a durable identity, otherwise null. Return {"candidates":[]}
|
|
26
|
+
when nothing qualifies.`;
|
|
27
|
+
export function parseExtraction(response) {
|
|
28
|
+
const parsed = extractJson(response);
|
|
29
|
+
if (!Array.isArray(parsed.candidates))
|
|
30
|
+
throw new Error("extraction response has no candidates");
|
|
31
|
+
const result = [];
|
|
32
|
+
for (const raw of parsed.candidates) {
|
|
33
|
+
const value = raw;
|
|
34
|
+
if (typeof value.text !== "string" || !value.text.trim())
|
|
35
|
+
continue;
|
|
36
|
+
result.push({
|
|
37
|
+
text: value.text.trim(),
|
|
38
|
+
key: typeof value.key === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.key)
|
|
39
|
+
? value.key
|
|
40
|
+
: null,
|
|
41
|
+
reason: typeof value.reason === "string" ? value.reason : "",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return result.slice(0, 24);
|
|
45
|
+
}
|
|
46
|
+
export async function runSessions(context, executor, options = {}) {
|
|
47
|
+
if (!options.dryRun)
|
|
48
|
+
return executeSessions(context, executor, options);
|
|
49
|
+
const store = context.store.fork();
|
|
50
|
+
try {
|
|
51
|
+
return await executeSessions({ ...context, store }, executor, options);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function executeSessions(context, executor, options) {
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const dryRun = options.dryRun === true;
|
|
60
|
+
const report = {
|
|
61
|
+
sessionsIngested: 0,
|
|
62
|
+
candidatesExtracted: 0,
|
|
63
|
+
rejected: [],
|
|
64
|
+
dryRun,
|
|
65
|
+
ingestion: {
|
|
66
|
+
messages: 0,
|
|
67
|
+
fragments: 0,
|
|
68
|
+
completed: 0,
|
|
69
|
+
cached: 0,
|
|
70
|
+
characters: 0,
|
|
71
|
+
remaining: 0,
|
|
72
|
+
cachedSessions: 0,
|
|
73
|
+
unscannedSessions: 0,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
if (!executor) {
|
|
77
|
+
options.observe?.({
|
|
78
|
+
phase: "sessions.disabled",
|
|
79
|
+
message: "No sessions model configured; nothing to extract",
|
|
80
|
+
level: "warn",
|
|
81
|
+
});
|
|
82
|
+
report.elapsedMs = Date.now() - started;
|
|
83
|
+
return report;
|
|
84
|
+
}
|
|
85
|
+
check(options);
|
|
86
|
+
options.observe?.({ phase: "sessions.scan", message: "Snapshotting eligible session messages" });
|
|
87
|
+
if (context.store.getMeta(WATERMARK_KEY) &&
|
|
88
|
+
!context.store.db
|
|
89
|
+
.prepare("SELECT 1 FROM meta WHERE key LIKE 'ingest-v2:%' OR key LIKE 'ingest-session-v2:%' LIMIT 1")
|
|
90
|
+
.get())
|
|
91
|
+
options.observe?.({
|
|
92
|
+
phase: "sessions.migration",
|
|
93
|
+
message: "Legacy cursor has no message revisions: checking existing history once; completed fragments will be cached",
|
|
94
|
+
level: "warn",
|
|
95
|
+
});
|
|
96
|
+
const databaseFile = options.databaseFile ?? opencodeDatabasePath();
|
|
97
|
+
if (!options.databaseFile &&
|
|
98
|
+
!(await stat(databaseFile).catch((error) => {
|
|
99
|
+
if (error.code === "ENOENT")
|
|
100
|
+
return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}))) {
|
|
103
|
+
options.observe?.({
|
|
104
|
+
phase: "sessions.unavailable",
|
|
105
|
+
message: "No OpenCode database exists yet; nothing to extract",
|
|
106
|
+
level: "warn",
|
|
107
|
+
});
|
|
108
|
+
report.elapsedMs = Date.now() - started;
|
|
109
|
+
return report;
|
|
110
|
+
}
|
|
111
|
+
let plan;
|
|
112
|
+
try {
|
|
113
|
+
plan = planIngestion(options.databaseFile ?? opencodeDatabasePath(), context.store, {
|
|
114
|
+
before: Date.now() - context.settings.sessions.idleMs,
|
|
115
|
+
maxChars: context.settings.sessions.maxChars,
|
|
116
|
+
maxSessions: context.settings.sessions.maxSessions,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw new Error(`cannot read OpenCode sessions: ${error.message}`);
|
|
121
|
+
}
|
|
122
|
+
report.ingestion = {
|
|
123
|
+
messages: plan.messages,
|
|
124
|
+
fragments: plan.fragments.length,
|
|
125
|
+
completed: 0,
|
|
126
|
+
cached: plan.cached,
|
|
127
|
+
characters: plan.characters,
|
|
128
|
+
remaining: plan.fragments.length,
|
|
129
|
+
cachedSessions: plan.cachedSessions,
|
|
130
|
+
unscannedSessions: plan.unscannedSessions,
|
|
131
|
+
};
|
|
132
|
+
if (!options.dryRun)
|
|
133
|
+
for (const [key, value] of plan.seals)
|
|
134
|
+
context.store.setMeta(key, value);
|
|
135
|
+
options.observe?.({
|
|
136
|
+
phase: "sessions.plan",
|
|
137
|
+
message: "Session snapshot ready",
|
|
138
|
+
total: plan.fragments.length,
|
|
139
|
+
completed: 0,
|
|
140
|
+
messages: plan.messages,
|
|
141
|
+
cached: plan.cached,
|
|
142
|
+
characters: plan.characters,
|
|
143
|
+
});
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const file of await corpusFiles(context.paths))
|
|
146
|
+
for (const entry of parseCorpusEntries((await readTextIfExists(file)) ?? "", file))
|
|
147
|
+
seen.add(entry.text);
|
|
148
|
+
const rejected = [];
|
|
149
|
+
const sessions = new Set();
|
|
150
|
+
const extracted = [];
|
|
151
|
+
for (const fragment of plan.fragments) {
|
|
152
|
+
check(options);
|
|
153
|
+
options.observe?.({
|
|
154
|
+
phase: "sessions.extract",
|
|
155
|
+
message: "Extracting a new or changed fragment",
|
|
156
|
+
completed: report.ingestion.completed,
|
|
157
|
+
total: report.ingestion.fragments,
|
|
158
|
+
fragment: fragment.id,
|
|
159
|
+
});
|
|
160
|
+
const receipt = join(context.paths.historyDir, "ingestion", `${fragment.id}.json`);
|
|
161
|
+
const cached = await readTextIfExists(receipt);
|
|
162
|
+
let candidates;
|
|
163
|
+
if (cached) {
|
|
164
|
+
const value = JSON.parse(cached);
|
|
165
|
+
if (value.version !== 1 || value.id !== fragment.id)
|
|
166
|
+
throw new Error("invalid ingestion checkpoint");
|
|
167
|
+
candidates = parseExtraction(JSON.stringify({ candidates: value.candidates }));
|
|
168
|
+
options.observe?.({
|
|
169
|
+
phase: "sessions.resume",
|
|
170
|
+
message: "Reusing completed model response",
|
|
171
|
+
fragment: fragment.id,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
candidates = parseExtraction(await executor.complete({
|
|
176
|
+
system: EXTRACT_SYSTEM,
|
|
177
|
+
prompt: `Session: ${fragment.sessionId}\nTitle: ${fragment.title}\nWorking directory: ${fragment.directory}\nMessage IDs: ${fragment.messageIds.join(",")}\nPrevious context (do not extract again):\n${fragment.context}\n\nNew or changed fragment:\n${fragment.text}`,
|
|
178
|
+
}));
|
|
179
|
+
if (!options.dryRun && !cached)
|
|
180
|
+
await writeAtomic(receipt, Buffer.from(JSON.stringify({
|
|
181
|
+
version: 1,
|
|
182
|
+
id: fragment.id,
|
|
183
|
+
sessionId: fragment.sessionId,
|
|
184
|
+
messageIds: fragment.messageIds,
|
|
185
|
+
candidates: candidates.filter((candidate) => !isForbidden(candidate.text, context.rules)),
|
|
186
|
+
})), 0o600);
|
|
187
|
+
check(options);
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
if (isForbidden(candidate.text, context.rules)) {
|
|
190
|
+
rejected.push({ text: candidate.text, reason: "never-save rule" });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
extracted.push(candidate);
|
|
194
|
+
const line = entryLine(candidate.text, {
|
|
195
|
+
key: candidate.key ?? undefined,
|
|
196
|
+
origin: "agent",
|
|
197
|
+
source: "opencode",
|
|
198
|
+
observed: new Date().toISOString().slice(0, 10),
|
|
199
|
+
});
|
|
200
|
+
const parsed = parseEntryLine(line);
|
|
201
|
+
const text = parsed?.text ?? `- ${candidate.text}`;
|
|
202
|
+
if (!options.dryRun && !seen.has(text))
|
|
203
|
+
await appendDailyEntry(context.paths, line);
|
|
204
|
+
seen.add(text);
|
|
205
|
+
}
|
|
206
|
+
if (!options.dryRun) {
|
|
207
|
+
for (const key of fragment.checkpointKeys)
|
|
208
|
+
context.store.setMeta(key, fragment.id);
|
|
209
|
+
if (fragment.seal)
|
|
210
|
+
context.store.setMeta(...fragment.seal);
|
|
211
|
+
}
|
|
212
|
+
sessions.add(fragment.sessionId);
|
|
213
|
+
report.ingestion.completed++;
|
|
214
|
+
report.ingestion.remaining--;
|
|
215
|
+
options.observe?.({
|
|
216
|
+
phase: "sessions.checkpoint",
|
|
217
|
+
message: "Fragment checkpoint committed",
|
|
218
|
+
completed: report.ingestion.completed,
|
|
219
|
+
total: report.ingestion.fragments,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!options.dryRun && sessions.size) {
|
|
223
|
+
context.store.setMeta(WATERMARK_KEY, String(plan.watermark));
|
|
224
|
+
context.store.setMeta(LAST_RUN_KEY, new Date().toISOString());
|
|
225
|
+
}
|
|
226
|
+
await reindex(context.paths, context.settings, context.store, options);
|
|
227
|
+
report.sessionsIngested = sessions.size;
|
|
228
|
+
report.candidatesExtracted = extracted.length;
|
|
229
|
+
report.rejected = rejected;
|
|
230
|
+
report.elapsedMs = Date.now() - started;
|
|
231
|
+
options.observe?.({
|
|
232
|
+
phase: "sessions.done",
|
|
233
|
+
message: "Session extraction complete",
|
|
234
|
+
elapsedMs: report.elapsedMs,
|
|
235
|
+
...report.ingestion,
|
|
236
|
+
});
|
|
237
|
+
return report;
|
|
238
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type MemomaticSettings = {
|
|
2
|
+
embedding: {
|
|
3
|
+
url: string;
|
|
4
|
+
model: string;
|
|
5
|
+
queryInstruction?: string;
|
|
6
|
+
} | null;
|
|
7
|
+
sessions: {
|
|
8
|
+
model: string | null;
|
|
9
|
+
variant: string | null;
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
maxDurationMs: number;
|
|
12
|
+
maxSessions: number;
|
|
13
|
+
maxChars: number;
|
|
14
|
+
retries: number;
|
|
15
|
+
idleMs: number;
|
|
16
|
+
opencodeUrl: string | null;
|
|
17
|
+
};
|
|
18
|
+
dream: {
|
|
19
|
+
model: string | null;
|
|
20
|
+
variant: string | null;
|
|
21
|
+
minScore: number;
|
|
22
|
+
minUseful: number;
|
|
23
|
+
minUniqueQueries: number;
|
|
24
|
+
maxCandidates: number;
|
|
25
|
+
memoryBudgetLines: number;
|
|
26
|
+
maxPriorEntryLoss: number;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
maxDurationMs: number;
|
|
29
|
+
retries: number;
|
|
30
|
+
};
|
|
31
|
+
search: {
|
|
32
|
+
maxResults: number;
|
|
33
|
+
minScore: number;
|
|
34
|
+
minSemanticScore: number;
|
|
35
|
+
halfLifeDays: number;
|
|
36
|
+
};
|
|
37
|
+
archive: {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
days: number;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export declare const defaultSettings: () => MemomaticSettings;
|
|
43
|
+
export declare function normalizeSettings(raw: unknown): MemomaticSettings;
|
|
44
|
+
export declare function loadSettings(settingsFile: string): Promise<MemomaticSettings>;
|
|
45
|
+
export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query", signal?: AbortSignal): Promise<Float32Array[] | null>;
|
|
46
|
+
export declare const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
|
|
47
|
+
export declare function embeddingQuery(settings: MemomaticSettings, query: string): string;
|
|
48
|
+
export declare function embeddingFingerprint(settings: MemomaticSettings): string;
|