opencode-memory-pro 1.3.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/dist/llm.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export interface LLMCapturedItem {
2
+ content: string;
3
+ type: "decision" | "fact" | "preference" | "other";
4
+ importance: number;
5
+ }
6
+ export interface LLMConfig {
7
+ provider?: string;
8
+ model?: string;
9
+ }
10
+ export declare function parseExtractionJSON(raw: string): LLMCapturedItem[] | null;
11
+ export declare function isOwnSession(sessionID: unknown): boolean;
12
+ export declare function extractAssistantText(response: unknown): string;
13
+ export declare function requestLLMCapture(client: unknown, llmConfig: LLMConfig | undefined, sessionText: string, sessionID?: string): Promise<LLMCapturedItem[] | null>;
14
+ export declare function requestLLMDigest(client: unknown, llmConfig: LLMConfig | undefined, texts: string[], targetChars: number, groupKey?: string): Promise<{ text: string; sourceCount: number } | null>;
package/dist/llm.js ADDED
@@ -0,0 +1,212 @@
1
+ // LLM_CAPTURE (1.1): SDK-transport LLM extraction and digest generation for
2
+ // opencode-memory-pro.
3
+ import { log } from "./logger.js";
4
+ //
5
+ // Transport rules (per design):
6
+ // - The LLM is addressed by opencode provider ID + model ID. opencode owns
7
+ // routing, auth, and base URLs; this module never sees an API key.
8
+ // - Calls run through ephemeral SDK sessions (created, prompted, deleted),
9
+ // isolated from user-visible history.
10
+ // - Every entry point is failure-tolerant: returns null/[] on any error so
11
+ // callers can fall back to the offline heuristics pipeline.
12
+ // - Tools are disabled on the ephemeral prompt so extraction/summarization
13
+ // is a pure text-in/text-out call.
14
+ const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction system for an AI coding assistant.
15
+
16
+ Read the conversation transcript below and extract DURABLE, memory-worthy content: decisions made, preferences expressed, durable facts about the user's projects/systems, and context that will matter weeks from now.
17
+
18
+ Ignore: greetings, small talk, ephemeral task details, raw tool output that contains no decision, and anything that will not matter later.
19
+
20
+ Return ONLY valid JSON — an array of objects, with no markdown fences and no commentary:
21
+ [{"content": "...", "type": "fact|decision|preference|other", "importance": 0.0-1.0}]
22
+
23
+ Rules:
24
+ - content: 1-3 self-contained sentences (no bare pronouns). One memory per item.
25
+ - type: "decision" (a choice was made), "fact" (durable fact), "preference" (user's stated preference), "other".
26
+ - importance: 0.0 (trivial) to 1.0 (critical). Be selective: most transcripts yield 1-4 memories.
27
+ - Emit at most 8 memories per transcript.`;
28
+ const DIGEST_SYSTEM_PROMPT = `You are a memory summarization system for an AI coding assistant.
29
+
30
+ Below is a set of memories about the same topic. Write ONE concise abstractive summary (target length: TARGETCHARS characters; topic: GROUPKEY) that a future agent can read to regain the essentials.
31
+
32
+ Preserve: decisions, root causes, concrete facts, user preferences, and any names/versions that matter. Omit repetition and low-value detail.
33
+
34
+ Return ONLY the summary text. No markdown headers, no commentary.`;
35
+ const VALID_CAPTURE_TYPES = ["decision", "fact", "preference", "other"];
36
+ const MAX_EXTRACTIONS = 8;
37
+ const MAX_CAPTURE_INPUT_CHARS = 60000;
38
+ const MAX_DIGEST_INPUT_CHARS = 80000;
39
+ /**
40
+ * Session IDs of ephemeral sessions this plugin created itself. Their own
41
+ * events (idle/deleted/text.complete) must never be fed back into the
42
+ * capture/consolidate pipeline, or the plugin would loop on itself,
43
+ * spawning unbounded LLM capture sessions and stacking consolidate passes.
44
+ */
45
+ const OWN_SESSION_IDS = new Set();
46
+ export function isOwnSession(sessionID) {
47
+ return typeof sessionID === "string" && OWN_SESSION_IDS.has(sessionID);
48
+ }
49
+ /**
50
+ * Tolerant JSON parse of the extraction model's reply. Accepts a bare array
51
+ * or an object wrapping an array under "memories"/"items"; strips markdown
52
+ * fences. Returns a normalized [{content,type,importance}] list, or null.
53
+ */
54
+ export function parseExtractionJSON(raw) {
55
+ if (typeof raw !== "string")
56
+ return null;
57
+ let cleaned = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
58
+ if (!cleaned)
59
+ return null;
60
+ let data;
61
+ try {
62
+ data = JSON.parse(cleaned);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ let list = Array.isArray(data)
68
+ ? data
69
+ : (Array.isArray(data?.memories) ? data.memories
70
+ : (Array.isArray(data?.items) ? data.items : null));
71
+ if (list === null)
72
+ return null;
73
+ if (list.length === 0)
74
+ return [];
75
+ const out = [];
76
+ for (const item of list.slice(0, MAX_EXTRACTIONS)) {
77
+ const content = typeof item?.content === "string" ? item.content.trim() : "";
78
+ if (!content)
79
+ continue;
80
+ const type = VALID_CAPTURE_TYPES.includes(item?.type) ? item.type : "other";
81
+ let importance = Number(item?.importance);
82
+ if (!Number.isFinite(importance)) {
83
+ importance = type === "decision" ? 0.9 : type === "fact" ? 0.75 : 0.65;
84
+ }
85
+ importance = Math.max(0, Math.min(1, importance));
86
+ out.push({ content, type, importance });
87
+ }
88
+ return out.length > 0 ? out : null;
89
+ }
90
+ /**
91
+ * Pulls concatenated assistant text parts out of a session.prompt response
92
+ * (opencode SDK shape: { data: { info, parts } }).
93
+ */
94
+ export function extractAssistantText(response) {
95
+ const payload = response && typeof response === "object" && "data" in response ? response.data : response;
96
+ const parts = payload?.parts;
97
+ if (!Array.isArray(parts))
98
+ return "";
99
+ return parts
100
+ .filter((p) => p?.type === "text" && typeof p?.text === "string")
101
+ .map((p) => p.text)
102
+ .join("\n")
103
+ .trim();
104
+ }
105
+ function hasUsableConfig(llmConfig, client) {
106
+ return Boolean(client?.session?.create && client?.session?.prompt && client?.session?.delete
107
+ && llmConfig?.provider && llmConfig?.model);
108
+ }
109
+ /**
110
+ * Runs one structured extraction pass over a session transcript via the
111
+ * opencode SDK. Returns [{content,type,importance}] on success — including []
112
+ * when the model finds nothing — or null on any failure (unreachable provider,
113
+ * timeout, unparseable reply, missing config).
114
+ */
115
+ export async function requestLLMCapture(client, llmConfig, sessionText, sessionID) {
116
+ if (!hasUsableConfig(llmConfig, client))
117
+ return null;
118
+ const text = typeof sessionText === "string" ? sessionText.trim() : "";
119
+ if (!text)
120
+ return null;
121
+ const input = text.length > MAX_CAPTURE_INPUT_CHARS ? text.slice(0, MAX_CAPTURE_INPUT_CHARS) : text;
122
+ const userPart = `Conversation transcript (${sessionID ? `session ${sessionID}` : "session"}):\n\n${input}\n\nExtract memories now.`;
123
+ const reply = await runEphemeralPrompt(client, llmConfig, EXTRACTION_SYSTEM_PROMPT, userPart, "memory-capture");
124
+ if (reply === null) {
125
+ log("warn", "[capture] llm extraction returned nothing; falling back to heuristics");
126
+ return null;
127
+ }
128
+ const parsed = parseExtractionJSON(reply);
129
+ if (parsed === null) {
130
+ log("warn", `[capture] llm extraction reply was not parseable as structured JSON: ${reply.slice(0, 300)}`);
131
+ return null;
132
+ }
133
+ if (parsed.length === 0) {
134
+ log("info", "[capture] llm extraction succeeded but produced no memories; falling back to heuristics");
135
+ return parsed;
136
+ }
137
+ return parsed;
138
+ }
139
+ /**
140
+ * Generates one abstractive LLM digest for a group of memories. Returns
141
+ * { text, sourceCount } or null on any failure (caller falls back to the
142
+ * offline extractive digest).
143
+ */
144
+ export async function requestLLMDigest(client, llmConfig, texts, targetChars, groupKey) {
145
+ if (!hasUsableConfig(llmConfig, client))
146
+ return null;
147
+ const safeTexts = Array.isArray(texts) ? texts.filter((t) => typeof t === "string" && t.trim().length > 0) : [];
148
+ if (safeTexts.length === 0)
149
+ return null;
150
+ let joined = safeTexts.map((t, i) => `[${i + 1}] ${t.trim()}`).join("\n\n");
151
+ if (joined.length > MAX_DIGEST_INPUT_CHARS) {
152
+ joined = joined.slice(0, MAX_DIGEST_INPUT_CHARS);
153
+ }
154
+ const userPart = `Topic: ${groupKey ?? "memories"}\nTarget length: ${Math.max(100, Number(targetChars) || 500)} characters\n\n${joined}\n\nWrite the summary now.`;
155
+ const system = DIGEST_SYSTEM_PROMPT
156
+ .replace("TARGETCHARS", String(Math.max(100, Number(targetChars) || 500)))
157
+ .replace("GROUPKEY", groupKey ?? "memories");
158
+ const reply = await runEphemeralPrompt(client, llmConfig, system, userPart, "memory-digest");
159
+ if (reply === null || reply.length === 0) {
160
+ log("warn", `[digest] llm digest failed for "${groupKey ?? "memories"}"; falling back to extractive digest`);
161
+ return null;
162
+ }
163
+ return { text: reply, sourceCount: safeTexts.length };
164
+ }
165
+ /**
166
+ * Shared ephemeral-session round trip: create → prompt (tools disabled) →
167
+ * delete (in finally). Returns the assistant's text, or null on failure.
168
+ */
169
+ async function runEphemeralPrompt(client, llmConfig, system, userText, title) {
170
+ let sessionId = null;
171
+ try {
172
+ const created = await client.session.create({
173
+ body: { title: `opencode-memory-pro ${title}` },
174
+ });
175
+ const createdPayload = created && typeof created === "object" && "data" in created ? created.data : created;
176
+ sessionId = createdPayload?.id;
177
+ if (!sessionId) {
178
+ log("warn", `[llm] ${title}: session.create did not return an id (got ${JSON.stringify(createdPayload)?.slice(0, 200)})`);
179
+ return null;
180
+ }
181
+ OWN_SESSION_IDS.add(sessionId);
182
+ const response = await client.session.prompt({
183
+ path: { id: sessionId },
184
+ body: {
185
+ system,
186
+ parts: [{ type: "text", text: userText }],
187
+ model: { providerID: llmConfig.provider, modelID: llmConfig.model },
188
+ tools: {},
189
+ },
190
+ });
191
+ const text = extractAssistantText(response);
192
+ if (!text) {
193
+ log("warn", `[llm] ${title}: session.prompt succeeded but returned no text parts (provider=${llmConfig.provider}, model=${llmConfig.model})`);
194
+ return null;
195
+ }
196
+ return text;
197
+ }
198
+ catch (error) {
199
+ log("warn", `[llm] ${title}: ${error instanceof Error ? error.message : String(error)} (provider=${llmConfig.provider}, model=${llmConfig.model})`);
200
+ return null;
201
+ }
202
+ finally {
203
+ if (sessionId) {
204
+ try {
205
+ await client.session.delete({ path: { id: sessionId } });
206
+ }
207
+ catch (error) {
208
+ log("warn", `[llm] ${title}: ephemeral session cleanup failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
209
+ }
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,9 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk";
2
+ type LogLevel = "debug" | "info" | "warn" | "error";
3
+ export declare function initLogger(client: OpencodeClient): void;
4
+ export declare function configureLogger(opts: {
5
+ logLevel?: string;
6
+ logFile?: string;
7
+ }): void;
8
+ export declare function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void;
9
+ export {};
package/dist/logger.js ADDED
@@ -0,0 +1,126 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const SERVICE_NAME = "opencode-memory-pro";
6
+ const LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
7
+
8
+ let _client = null;
9
+ let _minLevel = LOG_LEVELS.info;
10
+ let _logFile = null;
11
+
12
+ function expandHomePath(path) {
13
+ if (typeof path !== "string" || path.length === 0)
14
+ return path;
15
+ if (path === "~")
16
+ return homedir();
17
+ if (path.startsWith("~/"))
18
+ return join(homedir(), path.slice(2));
19
+ if (!isAbsolute(path))
20
+ return join(process.cwd(), path);
21
+ return path;
22
+ }
23
+
24
+ function formatLine(level, message, extra) {
25
+ const timestamp = new Date().toISOString();
26
+ let line = `[${timestamp}] [${level.toUpperCase().padEnd(5)}] [${SERVICE_NAME}] ${message}`;
27
+ if (extra !== undefined) {
28
+ try {
29
+ line += ` ${JSON.stringify(extra)}`;
30
+ }
31
+ catch {
32
+ line += ` ${String(extra)}`;
33
+ }
34
+ }
35
+ return line;
36
+ }
37
+
38
+ function writeFileLog(level, message, extra) {
39
+ if (!_logFile)
40
+ return;
41
+ try {
42
+ appendFileSync(_logFile, formatLine(level, message, extra) + "\n", "utf8");
43
+ }
44
+ catch {
45
+ // File sink must never throw into the plugin flow — if the file is
46
+ // unwritable we degrade silently; the bus/console fallback still runs.
47
+ }
48
+ }
49
+
50
+ export function initLogger(client) {
51
+ _client = client;
52
+ if (process.env.OPENCODE_MEMORY_PRO_LOG_FILE) {
53
+ configureLogger({
54
+ logLevel: process.env.OPENCODE_MEMORY_PRO_LOG_LEVEL,
55
+ logFile: process.env.OPENCODE_MEMORY_PRO_LOG_FILE,
56
+ });
57
+ }
58
+ }
59
+
60
+ // Applies logger settings. Called from the plugin config hook once sidecar
61
+ // config has been resolved (see resolveMemoryConfig -> logging section).
62
+ // Idempotent: safe to call on every config re-resolution.
63
+ export function configureLogger(opts = {}) {
64
+ if (opts && typeof opts === "object") {
65
+ // Accept both spellings: env path passes logLevel/logFile (initLogger),
66
+ // config path passes level/file (resolveMemoryConfig -> logging).
67
+ const file = opts.logFile ?? opts.file;
68
+ if (file) {
69
+ const expanded = expandHomePath(file);
70
+ try {
71
+ mkdirSync(dirname(expanded), { recursive: true });
72
+ _logFile = expanded;
73
+ }
74
+ catch (error) {
75
+ const err = error instanceof Error ? error.message : String(error);
76
+ console.warn(`[${SERVICE_NAME}] failed to open log file ${expanded}: ${err}`);
77
+ _logFile = null;
78
+ }
79
+ }
80
+ const level = opts.logLevel ?? opts.level;
81
+ if (level && LOG_LEVELS[level] !== undefined) {
82
+ _minLevel = LOG_LEVELS[level];
83
+ }
84
+ }
85
+ }
86
+
87
+ // Routes to client.app.log() when SDK client is bound, otherwise falls back to
88
+ // console. Also appends to the configured log file when one is set.
89
+ export function log(level, message, extra) {
90
+ const lvl = LOG_LEVELS[level] ?? LOG_LEVELS.info;
91
+ if (lvl < _minLevel)
92
+ return;
93
+ writeFileLog(level, message, extra);
94
+ if (_client?.app?.log) {
95
+ _client.app
96
+ .log({
97
+ body: {
98
+ service: SERVICE_NAME,
99
+ level,
100
+ message,
101
+ ...(extra !== undefined ? { extra } : {}),
102
+ },
103
+ })
104
+ .catch(() => consoleFallback(level, message));
105
+ return;
106
+ }
107
+ consoleFallback(level, message);
108
+ }
109
+
110
+ function consoleFallback(level, message) {
111
+ const formatted = `[${SERVICE_NAME}] ${message}`;
112
+ switch (level) {
113
+ case "error":
114
+ console.error(formatted);
115
+ break;
116
+ case "warn":
117
+ console.warn(formatted);
118
+ break;
119
+ case "info":
120
+ console.info(formatted);
121
+ break;
122
+ default:
123
+ console.log(formatted);
124
+ break;
125
+ }
126
+ }
@@ -0,0 +1,34 @@
1
+ import type { MemoryRecord } from "./types.js";
2
+ export interface PortServiceRequest {
3
+ name: string;
4
+ containerPort: number;
5
+ preferredHostPort?: number;
6
+ }
7
+ export interface PortReservation {
8
+ id: string;
9
+ project: string;
10
+ service: string;
11
+ hostPort: number;
12
+ containerPort: number;
13
+ protocol: "tcp";
14
+ }
15
+ export interface PortAssignment {
16
+ project: string;
17
+ service: string;
18
+ hostPort: number;
19
+ containerPort: number;
20
+ protocol: "tcp";
21
+ }
22
+ export interface PlanPortsInput {
23
+ project: string;
24
+ services: PortServiceRequest[];
25
+ rangeStart: number;
26
+ rangeEnd: number;
27
+ reservations: PortReservation[];
28
+ }
29
+ type PortChecker = (port: number) => Promise<boolean>;
30
+ export declare function parsePortReservations(records: MemoryRecord[]): PortReservation[];
31
+ export declare function planPorts(input: PlanPortsInput, checker?: PortChecker): Promise<PortAssignment[]>;
32
+ export declare function reservationKey(project: string, service: string, protocol: "tcp"): string;
33
+ export declare function isTcpPortAvailable(port: number): Promise<boolean>;
34
+ export {};
package/dist/ports.js ADDED
@@ -0,0 +1,129 @@
1
+ import { createServer } from "node:net";
2
+ export function parsePortReservations(records) {
3
+ const parsed = [];
4
+ for (const record of records) {
5
+ let metadata;
6
+ try {
7
+ metadata = JSON.parse(record.metadataJson);
8
+ }
9
+ catch {
10
+ continue;
11
+ }
12
+ if (!isPortReservationMetadata(metadata))
13
+ continue;
14
+ parsed.push({
15
+ id: record.id,
16
+ project: metadata.project,
17
+ service: metadata.service,
18
+ hostPort: metadata.hostPort,
19
+ containerPort: metadata.containerPort,
20
+ protocol: "tcp",
21
+ });
22
+ }
23
+ return parsed;
24
+ }
25
+ export async function planPorts(input, checker = isTcpPortAvailable) {
26
+ const reservedByPort = new Map();
27
+ for (const reservation of input.reservations) {
28
+ const key = reservationKey(reservation.project, reservation.service, reservation.protocol);
29
+ if (!reservedByPort.has(reservation.hostPort)) {
30
+ reservedByPort.set(reservation.hostPort, new Set());
31
+ }
32
+ reservedByPort.get(reservation.hostPort)?.add(key);
33
+ }
34
+ const occupied = new Set();
35
+ const planUsed = new Set();
36
+ const checked = new Map();
37
+ const assignments = [];
38
+ for (const service of input.services) {
39
+ const serviceKey = reservationKey(input.project, service.name, "tcp");
40
+ const preferred = Number.isInteger(service.preferredHostPort) ? Number(service.preferredHostPort) : undefined;
41
+ const candidate = await pickCandidatePort({
42
+ preferredHostPort: preferred,
43
+ rangeStart: input.rangeStart,
44
+ rangeEnd: input.rangeEnd,
45
+ serviceKey,
46
+ reservedByPort,
47
+ occupied,
48
+ planUsed,
49
+ checked,
50
+ checker,
51
+ });
52
+ if (candidate === null) {
53
+ throw new Error(`No available host port for service '${service.name}' in range ${input.rangeStart}-${input.rangeEnd}.`);
54
+ }
55
+ planUsed.add(candidate);
56
+ occupied.add(candidate);
57
+ assignments.push({
58
+ project: input.project,
59
+ service: service.name,
60
+ hostPort: candidate,
61
+ containerPort: service.containerPort,
62
+ protocol: "tcp",
63
+ });
64
+ }
65
+ return assignments;
66
+ }
67
+ export function reservationKey(project, service, protocol) {
68
+ return `${project}\u0000${service}\u0000${protocol}`;
69
+ }
70
+ export async function isTcpPortAvailable(port) {
71
+ if (!isValidPort(port))
72
+ return false;
73
+ return new Promise((resolve) => {
74
+ const server = createServer();
75
+ const finish = (result) => {
76
+ server.removeAllListeners();
77
+ server.close(() => resolve(result));
78
+ };
79
+ server.once("error", () => finish(false));
80
+ server.once("listening", () => finish(true));
81
+ server.listen({ host: "0.0.0.0", port, exclusive: true });
82
+ });
83
+ }
84
+ function isPortReservationMetadata(value) {
85
+ if (!value || typeof value !== "object")
86
+ return false;
87
+ const data = value;
88
+ return data.type === "port-reservation"
89
+ && typeof data.project === "string"
90
+ && typeof data.service === "string"
91
+ && Number.isInteger(data.hostPort)
92
+ && Number.isInteger(data.containerPort)
93
+ && (data.protocol === undefined || data.protocol === "tcp");
94
+ }
95
+ async function pickCandidatePort(input) {
96
+ const candidates = [];
97
+ if (input.preferredHostPort !== undefined) {
98
+ candidates.push(input.preferredHostPort);
99
+ }
100
+ for (let port = input.rangeStart; port <= input.rangeEnd; port += 1) {
101
+ if (port === input.preferredHostPort)
102
+ continue;
103
+ candidates.push(port);
104
+ }
105
+ for (const port of candidates) {
106
+ if (!isValidPort(port))
107
+ continue;
108
+ if (input.planUsed.has(port) || input.occupied.has(port))
109
+ continue;
110
+ const owners = input.reservedByPort.get(port);
111
+ if (owners && (owners.size > 1 || !owners.has(input.serviceKey))) {
112
+ continue;
113
+ }
114
+ let free = input.checked.get(port);
115
+ if (free === undefined) {
116
+ free = await input.checker(port);
117
+ input.checked.set(port, free);
118
+ }
119
+ if (!free) {
120
+ input.occupied.add(port);
121
+ continue;
122
+ }
123
+ return port;
124
+ }
125
+ return null;
126
+ }
127
+ function isValidPort(port) {
128
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
129
+ }
@@ -0,0 +1,10 @@
1
+ import type { MemoryRecord, Preference, PreferenceScope, PreferenceSignal, PreferenceProfile } from "./types.js";
2
+ export declare function extractPreferenceSignals(memory: MemoryRecord): PreferenceSignal[];
3
+ export declare function aggregatePreferences(signals: PreferenceSignal[], scope: PreferenceScope): PreferenceProfile;
4
+ export declare function resolveConflicts(projectPrefs: Preference[], globalPrefs: Preference[]): Preference[];
5
+ export interface InjectionConfig {
6
+ mode: "budget" | "fixed";
7
+ maxMemories: number;
8
+ tokenBudget?: number;
9
+ }
10
+ export declare function buildPreferenceInjection(preferences: Preference[], config: InjectionConfig): string;
@@ -0,0 +1,125 @@
1
+ const PREFERENCE_PATTERNS = [
2
+ { regex: /I prefer (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" },
3
+ { regex: /I (?:always |)(?:use |use |using )([\w#.+-]+)/i, category: "tool", source: "explicit" },
4
+ { regex: /(?:prefer|preferred) (?:to |)([\w#.+-]+)/i, category: "tool", source: "explicit" },
5
+ { regex: /use ([\w#.+-]+) (?:for |)/i, category: "tool", source: "explicit" },
6
+ { regex: /I like (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" },
7
+ { regex: /(typescript|javascript|python|rust|go|java)/i, category: "language", source: "explicit" },
8
+ { regex: /(jest|vitest|mocha|pytest|rubocop|prettier)/i, category: "tool", source: "explicit" },
9
+ { regex: /(react|vue|angular|svelte)/i, category: "tool", source: "explicit" },
10
+ { regex: /(eslint|prettier|black|ruff|gofmt)/i, category: "style", source: "explicit" },
11
+ { regex: /avoid (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" },
12
+ { regex: /test(-|ing) (?:with |)([\w#.+-]+)/i, category: "tool", source: "explicit" },
13
+ ];
14
+ const DEFAULT_DECAY_HALF_LIFE_DAYS = 30;
15
+ export function extractPreferenceSignals(memory) {
16
+ const signals = [];
17
+ const text = memory.text;
18
+ for (const pattern of PREFERENCE_PATTERNS) {
19
+ const match = text.match(pattern.regex);
20
+ if (match) {
21
+ signals.push({
22
+ key: normalizePreferenceKey(match[1]),
23
+ value: match[1],
24
+ category: pattern.category,
25
+ source: pattern.source,
26
+ timestamp: memory.timestamp,
27
+ memoryId: memory.id,
28
+ });
29
+ }
30
+ }
31
+ return signals;
32
+ }
33
+ function normalizePreferenceKey(value) {
34
+ return value.toLowerCase().trim().replace(/\s+/g, "-");
35
+ }
36
+ export function aggregatePreferences(signals, scope) {
37
+ const preferenceMap = new Map();
38
+ for (const signal of signals) {
39
+ const existing = preferenceMap.get(signal.key);
40
+ if (existing) {
41
+ existing.count += 1;
42
+ if (signal.timestamp > existing.signal.timestamp) {
43
+ existing.signal = signal;
44
+ }
45
+ }
46
+ else {
47
+ preferenceMap.set(signal.key, { signal, count: 1 });
48
+ }
49
+ }
50
+ const preferences = [];
51
+ const now = Date.now();
52
+ for (const [key, data] of preferenceMap) {
53
+ const confidence = calculateConfidence(data.count, data.signal.timestamp, now);
54
+ preferences.push({
55
+ key,
56
+ value: data.signal.value,
57
+ category: data.signal.category,
58
+ confidence,
59
+ scope,
60
+ lastUpdated: data.signal.timestamp,
61
+ sourceCount: data.count,
62
+ });
63
+ }
64
+ preferences.sort((a, b) => b.confidence - a.confidence);
65
+ return {
66
+ scope,
67
+ preferences,
68
+ updatedAt: now,
69
+ };
70
+ }
71
+ function calculateConfidence(count, timestamp, now) {
72
+ const baseConfidence = Math.min(count / 5, 1);
73
+ const ageDays = (now - timestamp) / (1000 * 60 * 60 * 24);
74
+ const decayFactor = Math.pow(0.5, ageDays / DEFAULT_DECAY_HALF_LIFE_DAYS);
75
+ return baseConfidence * decayFactor;
76
+ }
77
+ export function resolveConflicts(projectPrefs, globalPrefs) {
78
+ const prefMap = new Map();
79
+ for (const pref of globalPrefs) {
80
+ prefMap.set(pref.key, { ...pref, scope: "global" });
81
+ }
82
+ for (const pref of projectPrefs) {
83
+ const existing = prefMap.get(pref.key);
84
+ if (!existing) {
85
+ prefMap.set(pref.key, { ...pref, scope: "project" });
86
+ }
87
+ else {
88
+ const winner = resolveSingleConflict(pref, existing);
89
+ prefMap.set(pref.key, winner);
90
+ }
91
+ }
92
+ return Array.from(prefMap.values()).sort((a, b) => b.confidence - a.confidence);
93
+ }
94
+ function resolveSingleConflict(a, b) {
95
+ if (a.lastUpdated > b.lastUpdated) {
96
+ return { ...a, scope: "project" };
97
+ }
98
+ return { ...b, scope: "global" };
99
+ }
100
+ export function buildPreferenceInjection(preferences, config) {
101
+ if (preferences.length === 0) {
102
+ return "";
103
+ }
104
+ const lines = [];
105
+ lines.push("## User Preferences");
106
+ if (config.mode === "fixed") {
107
+ const selected = preferences.slice(0, config.maxMemories);
108
+ for (const pref of selected) {
109
+ lines.push(`- [${pref.category}] ${pref.value} (confidence: ${Math.round(pref.confidence * 100)}%)`);
110
+ }
111
+ }
112
+ else {
113
+ let currentTokens = 0;
114
+ const budget = config.tokenBudget ?? 500;
115
+ for (const pref of preferences) {
116
+ const estimatedTokens = pref.value.length / 4;
117
+ if (currentTokens + estimatedTokens > budget) {
118
+ break;
119
+ }
120
+ lines.push(`- [${pref.category}] ${pref.value}`);
121
+ currentTokens += estimatedTokens;
122
+ }
123
+ }
124
+ return lines.join("\n");
125
+ }
@@ -0,0 +1,2 @@
1
+ export declare function deriveProjectScope(worktree: string): string;
2
+ export declare function buildScopeFilter(activeScope: string, includeGlobal: boolean): string[];