@syntesseraai/opencode-feature-factory 0.3.3 → 0.3.5
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/local-recall/daemon.js +48 -13
- package/dist/local-recall/processed-log.js +7 -4
- package/dist/local-recall/storage-reader.js +11 -1
- package/dist/local-recall/types.d.ts +18 -8
- package/dist/local-recall/vector/orama-index.d.ts +2 -0
- package/dist/local-recall/vector/orama-index.js +31 -2
- package/package.json +1 -1
|
@@ -23,32 +23,51 @@ import { getMemoriesDir, storeMemories } from './memory-service.js';
|
|
|
23
23
|
function getErrorMessage(error) {
|
|
24
24
|
return error instanceof Error ? error.message : String(error);
|
|
25
25
|
}
|
|
26
|
+
function toProcessedFailureEntry(failure, failureMessage) {
|
|
27
|
+
switch (failure.scope) {
|
|
28
|
+
case 'project':
|
|
29
|
+
return {
|
|
30
|
+
kind: 'failure',
|
|
31
|
+
scope: 'project',
|
|
32
|
+
processedAt: Date.now(),
|
|
33
|
+
failure: failureMessage,
|
|
34
|
+
directory: failure.directory,
|
|
35
|
+
};
|
|
36
|
+
case 'session':
|
|
37
|
+
return {
|
|
38
|
+
kind: 'failure',
|
|
39
|
+
scope: 'session',
|
|
40
|
+
processedAt: Date.now(),
|
|
41
|
+
failure: failureMessage,
|
|
42
|
+
sessionID: failure.sessionID,
|
|
43
|
+
};
|
|
44
|
+
case 'extraction':
|
|
45
|
+
return {
|
|
46
|
+
kind: 'failure',
|
|
47
|
+
scope: 'extraction',
|
|
48
|
+
processedAt: Date.now(),
|
|
49
|
+
failure: failureMessage,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
26
53
|
function recordFailure(stats, failure) {
|
|
27
54
|
let message;
|
|
28
|
-
let rawError;
|
|
29
55
|
switch (failure.scope) {
|
|
30
56
|
case 'project':
|
|
31
57
|
message = `No OpenCode project found for directory: ${failure.directory}`;
|
|
32
58
|
break;
|
|
33
59
|
case 'message':
|
|
34
60
|
message = `Error processing message ${failure.messageID}: ${getErrorMessage(failure.error)}`;
|
|
35
|
-
rawError = failure.error;
|
|
36
61
|
break;
|
|
37
62
|
case 'session':
|
|
38
63
|
message = `Error processing session ${failure.sessionID}: ${getErrorMessage(failure.error)}`;
|
|
39
|
-
rawError = failure.error;
|
|
40
64
|
break;
|
|
41
65
|
case 'extraction':
|
|
42
66
|
message = `Extraction failed: ${getErrorMessage(failure.error)}`;
|
|
43
|
-
rawError = failure.error;
|
|
44
67
|
break;
|
|
45
68
|
}
|
|
46
69
|
stats.errors.push(message);
|
|
47
|
-
|
|
48
|
-
console.error('[local-recall-daemon]', message, rawError);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
console.error('[local-recall-daemon]', message);
|
|
70
|
+
return message;
|
|
52
71
|
}
|
|
53
72
|
// ────────────────────────────────────────────────────────────
|
|
54
73
|
// Helpers
|
|
@@ -99,7 +118,14 @@ export async function runExtraction(directory) {
|
|
|
99
118
|
// Find project for this directory
|
|
100
119
|
const project = await findProject(directory);
|
|
101
120
|
if (!project) {
|
|
102
|
-
|
|
121
|
+
const failure = { scope: 'project', directory };
|
|
122
|
+
const failureMessage = recordFailure(stats, failure);
|
|
123
|
+
try {
|
|
124
|
+
await markProcessed(directory, [toProcessedFailureEntry(failure, failureMessage)]);
|
|
125
|
+
}
|
|
126
|
+
catch (persistErr) {
|
|
127
|
+
stats.errors.push(`Failed to persist processing failure: ${getErrorMessage(persistErr)}`);
|
|
128
|
+
}
|
|
103
129
|
return stats;
|
|
104
130
|
}
|
|
105
131
|
// Ensure local-recall directories exist
|
|
@@ -203,11 +229,13 @@ export async function runExtraction(directory) {
|
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
231
|
catch (err) {
|
|
206
|
-
|
|
232
|
+
const failure = {
|
|
207
233
|
scope: 'session',
|
|
208
234
|
sessionID: session.id,
|
|
209
235
|
error: err,
|
|
210
|
-
}
|
|
236
|
+
};
|
|
237
|
+
const failureMessage = recordFailure(stats, failure);
|
|
238
|
+
newProcessedEntries.push(toProcessedFailureEntry(failure, failureMessage));
|
|
211
239
|
}
|
|
212
240
|
}
|
|
213
241
|
// Batch store all new memories
|
|
@@ -221,7 +249,14 @@ export async function runExtraction(directory) {
|
|
|
221
249
|
}
|
|
222
250
|
}
|
|
223
251
|
catch (err) {
|
|
224
|
-
|
|
252
|
+
const failure = { scope: 'extraction', error: err };
|
|
253
|
+
const failureMessage = recordFailure(stats, failure);
|
|
254
|
+
try {
|
|
255
|
+
await markProcessed(directory, [toProcessedFailureEntry(failure, failureMessage)]);
|
|
256
|
+
}
|
|
257
|
+
catch (persistErr) {
|
|
258
|
+
stats.errors.push(`Failed to persist processing failure: ${getErrorMessage(persistErr)}`);
|
|
259
|
+
}
|
|
225
260
|
}
|
|
226
261
|
return stats;
|
|
227
262
|
}
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
13
13
|
import { join, dirname } from 'node:path';
|
|
14
|
+
function isProcessedMessageEntry(entry) {
|
|
15
|
+
return 'messageID' in entry && 'contentHash' in entry;
|
|
16
|
+
}
|
|
14
17
|
function getLogPath(directory) {
|
|
15
18
|
return join(directory, 'ff-memories', 'processed.json');
|
|
16
19
|
}
|
|
@@ -48,7 +51,7 @@ export async function readProcessedLog(directory) {
|
|
|
48
51
|
*/
|
|
49
52
|
export async function isProcessed(directory, messageID) {
|
|
50
53
|
const log = await readProcessedLog(directory);
|
|
51
|
-
return log.some((entry) => entry.messageID === messageID);
|
|
54
|
+
return log.some((entry) => isProcessedMessageEntry(entry) && entry.messageID === messageID);
|
|
52
55
|
}
|
|
53
56
|
/**
|
|
54
57
|
* Check if a content hash has already been processed.
|
|
@@ -56,7 +59,7 @@ export async function isProcessed(directory, messageID) {
|
|
|
56
59
|
*/
|
|
57
60
|
export async function isContentProcessed(directory, hash) {
|
|
58
61
|
const log = await readProcessedLog(directory);
|
|
59
|
-
return log.some((entry) => entry.contentHash === hash);
|
|
62
|
+
return log.some((entry) => isProcessedMessageEntry(entry) && entry.contentHash === hash);
|
|
60
63
|
}
|
|
61
64
|
/**
|
|
62
65
|
* Mark messages as processed by appending entries to the log.
|
|
@@ -72,11 +75,11 @@ export async function markProcessed(directory, entries) {
|
|
|
72
75
|
* Get the set of already-processed message IDs for fast lookup.
|
|
73
76
|
*/
|
|
74
77
|
export function getProcessedMessageIDs(log) {
|
|
75
|
-
return new Set(log.map((e) => e.messageID));
|
|
78
|
+
return new Set(log.filter(isProcessedMessageEntry).map((e) => e.messageID));
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
81
|
* Get the set of already-processed content hashes for fast lookup.
|
|
79
82
|
*/
|
|
80
83
|
export function getProcessedHashes(log) {
|
|
81
|
-
return new Set(log.map((e) => e.contentHash));
|
|
84
|
+
return new Set(log.filter(isProcessedMessageEntry).map((e) => e.contentHash));
|
|
82
85
|
}
|
|
@@ -45,6 +45,10 @@ async function dirExists(dirPath) {
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
function getPartStartTime(part) {
|
|
49
|
+
const start = part.time?.start;
|
|
50
|
+
return typeof start === 'number' ? start : Number.POSITIVE_INFINITY;
|
|
51
|
+
}
|
|
48
52
|
// ── Project Reader ──────────────────────────────────────────────
|
|
49
53
|
/**
|
|
50
54
|
* Find the project record whose worktree matches `directory`.
|
|
@@ -131,7 +135,13 @@ export async function listParts(messageID) {
|
|
|
131
135
|
if (!(await dirExists(partDir)))
|
|
132
136
|
return [];
|
|
133
137
|
const parts = await readAllJsonInDir(partDir);
|
|
134
|
-
return parts.sort((a, b) =>
|
|
138
|
+
return parts.sort((a, b) => {
|
|
139
|
+
const timeDelta = getPartStartTime(a) - getPartStartTime(b);
|
|
140
|
+
if (timeDelta !== 0) {
|
|
141
|
+
return timeDelta;
|
|
142
|
+
}
|
|
143
|
+
return a.id.localeCompare(b.id);
|
|
144
|
+
});
|
|
135
145
|
}
|
|
136
146
|
/**
|
|
137
147
|
* Get a single part by ID.
|
|
@@ -54,10 +54,10 @@ export interface OCPart {
|
|
|
54
54
|
messageID: string;
|
|
55
55
|
type: string;
|
|
56
56
|
text?: string;
|
|
57
|
-
synthetic
|
|
58
|
-
time
|
|
59
|
-
start
|
|
60
|
-
end
|
|
57
|
+
synthetic?: boolean;
|
|
58
|
+
time?: {
|
|
59
|
+
start?: number;
|
|
60
|
+
end?: number;
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
/** A memory extracted from a conversation turn */
|
|
@@ -119,24 +119,34 @@ export interface ExtractionResult {
|
|
|
119
119
|
/** Where the extraction came from — used to generate logical IDs */
|
|
120
120
|
source: 'session' | 'thinking';
|
|
121
121
|
}
|
|
122
|
-
interface
|
|
122
|
+
interface ProcessedMessageEntryBase {
|
|
123
123
|
messageID: string;
|
|
124
124
|
processedAt: number;
|
|
125
125
|
/** SHA-256 hex hash of the concatenated extracted bodies for content-level idempotency */
|
|
126
126
|
contentHash: string;
|
|
127
127
|
}
|
|
128
128
|
/** Tracks which messages have already been processed */
|
|
129
|
-
export type
|
|
129
|
+
export type ProcessedMessageEntry = (ProcessedMessageEntryBase & {
|
|
130
130
|
status: 'success';
|
|
131
131
|
memoriesCreated: number;
|
|
132
132
|
failure?: undefined;
|
|
133
|
-
}) | (
|
|
133
|
+
}) | (ProcessedMessageEntryBase & {
|
|
134
134
|
status: 'failed';
|
|
135
135
|
memoriesCreated: 0;
|
|
136
136
|
failure: string;
|
|
137
|
-
}) | (
|
|
137
|
+
}) | (ProcessedMessageEntryBase & {
|
|
138
138
|
status?: undefined;
|
|
139
139
|
memoriesCreated: number;
|
|
140
140
|
failure?: undefined;
|
|
141
141
|
});
|
|
142
|
+
/** Persistent daemon-level failures that are not tied to a specific message record. */
|
|
143
|
+
export type ProcessedFailureEntry = {
|
|
144
|
+
kind: 'failure';
|
|
145
|
+
scope: 'project' | 'session' | 'extraction';
|
|
146
|
+
processedAt: number;
|
|
147
|
+
failure: string;
|
|
148
|
+
directory?: string;
|
|
149
|
+
sessionID?: string;
|
|
150
|
+
};
|
|
151
|
+
export type ProcessedEntry = ProcessedMessageEntry | ProcessedFailureEntry;
|
|
142
152
|
export {};
|
|
@@ -5,6 +5,7 @@ export declare class OramaMemoryIndex {
|
|
|
5
5
|
private readonly provider;
|
|
6
6
|
private db;
|
|
7
7
|
private readonly documents;
|
|
8
|
+
private readonly embedMaxChars;
|
|
8
9
|
private dimensions;
|
|
9
10
|
private updatedAt;
|
|
10
11
|
constructor(directory: string, provider: EmbeddingProvider);
|
|
@@ -31,6 +32,7 @@ export declare class OramaMemoryIndex {
|
|
|
31
32
|
private persistSnapshot;
|
|
32
33
|
private writeAtomic;
|
|
33
34
|
private memoryToEmbeddingInput;
|
|
35
|
+
private prepareEmbeddingInput;
|
|
34
36
|
private toDocument;
|
|
35
37
|
private assertEmbeddingDimensions;
|
|
36
38
|
private embedBatch;
|
|
@@ -3,9 +3,23 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
const INDEX_VERSION = 1;
|
|
5
5
|
const EMBED_BATCH_SIZE = 16;
|
|
6
|
+
const DEFAULT_EMBED_MAX_CHARS = 6000;
|
|
7
|
+
const MIN_EMBED_MAX_CHARS = 256;
|
|
8
|
+
const MAX_EMBED_MAX_CHARS = 200_000;
|
|
9
|
+
const TRUNCATION_MARKER = '\n\n...[truncated]...\n\n';
|
|
6
10
|
function clamp(value, min, max) {
|
|
7
11
|
return Math.min(Math.max(value, min), max);
|
|
8
12
|
}
|
|
13
|
+
function resolveEmbedMaxChars(rawValue) {
|
|
14
|
+
if (!rawValue) {
|
|
15
|
+
return DEFAULT_EMBED_MAX_CHARS;
|
|
16
|
+
}
|
|
17
|
+
const parsedValue = Number.parseInt(rawValue, 10);
|
|
18
|
+
if (!Number.isFinite(parsedValue)) {
|
|
19
|
+
return DEFAULT_EMBED_MAX_CHARS;
|
|
20
|
+
}
|
|
21
|
+
return clamp(parsedValue, MIN_EMBED_MAX_CHARS, MAX_EMBED_MAX_CHARS);
|
|
22
|
+
}
|
|
9
23
|
function isVector(value) {
|
|
10
24
|
return Array.isArray(value) && value.every((entry) => typeof entry === 'number');
|
|
11
25
|
}
|
|
@@ -77,6 +91,7 @@ export class OramaMemoryIndex {
|
|
|
77
91
|
provider;
|
|
78
92
|
db = null;
|
|
79
93
|
documents = new Map();
|
|
94
|
+
embedMaxChars = resolveEmbedMaxChars(process.env.FF_LOCAL_RECALL_EMBED_MAX_CHARS);
|
|
80
95
|
dimensions = null;
|
|
81
96
|
updatedAt = null;
|
|
82
97
|
constructor(directory, provider) {
|
|
@@ -114,7 +129,7 @@ export class OramaMemoryIndex {
|
|
|
114
129
|
.map((document) => toSearchResult(document, 0));
|
|
115
130
|
return filtered;
|
|
116
131
|
}
|
|
117
|
-
const [queryEmbedding] = await this.provider.embed([query]);
|
|
132
|
+
const [queryEmbedding] = await this.provider.embed([this.prepareEmbeddingInput(query)]);
|
|
118
133
|
if (!queryEmbedding) {
|
|
119
134
|
return [];
|
|
120
135
|
}
|
|
@@ -332,7 +347,21 @@ export class OramaMemoryIndex {
|
|
|
332
347
|
await rename(tmpPath, filePath);
|
|
333
348
|
}
|
|
334
349
|
memoryToEmbeddingInput(memory) {
|
|
335
|
-
return `${memory.title}\n\n${memory.body}
|
|
350
|
+
return this.prepareEmbeddingInput(`${memory.title}\n\n${memory.body}`);
|
|
351
|
+
}
|
|
352
|
+
prepareEmbeddingInput(input) {
|
|
353
|
+
const normalized = input.trim();
|
|
354
|
+
if (normalized.length <= this.embedMaxChars) {
|
|
355
|
+
return normalized;
|
|
356
|
+
}
|
|
357
|
+
const availableContent = this.embedMaxChars - TRUNCATION_MARKER.length;
|
|
358
|
+
if (availableContent <= 1) {
|
|
359
|
+
return normalized.slice(0, this.embedMaxChars);
|
|
360
|
+
}
|
|
361
|
+
const headLength = Math.ceil(availableContent * 0.8);
|
|
362
|
+
const tailLength = availableContent - headLength;
|
|
363
|
+
const suffix = tailLength > 0 ? normalized.slice(-tailLength) : '';
|
|
364
|
+
return `${normalized.slice(0, headLength)}${TRUNCATION_MARKER}${suffix}`;
|
|
336
365
|
}
|
|
337
366
|
toDocument(memory, embedding) {
|
|
338
367
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenCode plugin for Feature Factory agents - provides sub-agents and skills for validation, review, security, and architecture assessment",
|
|
7
7
|
"license": "MIT",
|