@syntesseraai/opencode-feature-factory 0.4.2 → 0.4.4
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/agents/building.md +9 -6
- package/package.json +2 -2
- package/dist/discovery.test.d.ts +0 -10
- package/dist/discovery.test.js +0 -97
- package/dist/local-recall/daemon-controller.d.ts +0 -51
- package/dist/local-recall/daemon-controller.js +0 -166
- package/dist/local-recall/daemon.d.ts +0 -35
- package/dist/local-recall/daemon.js +0 -262
- package/dist/local-recall/index-state.d.ts +0 -14
- package/dist/local-recall/index-state.js +0 -76
- package/dist/local-recall/index.d.ts +0 -20
- package/dist/local-recall/index.js +0 -27
- package/dist/local-recall/mcp-server.d.ts +0 -34
- package/dist/local-recall/mcp-server.js +0 -194
- package/dist/local-recall/mcp-stdio-server.d.ts +0 -4
- package/dist/local-recall/mcp-stdio-server.js +0 -225
- package/dist/local-recall/mcp-tools.d.ts +0 -103
- package/dist/local-recall/mcp-tools.js +0 -187
- package/dist/local-recall/memory-service.d.ts +0 -32
- package/dist/local-recall/memory-service.js +0 -156
- package/dist/local-recall/model-router.d.ts +0 -23
- package/dist/local-recall/model-router.js +0 -41
- package/dist/local-recall/processed-log.d.ts +0 -41
- package/dist/local-recall/processed-log.js +0 -85
- package/dist/local-recall/prompt-injection.d.ts +0 -2
- package/dist/local-recall/prompt-injection.js +0 -194
- package/dist/local-recall/session-extractor.d.ts +0 -19
- package/dist/local-recall/session-extractor.js +0 -172
- package/dist/local-recall/storage-reader.d.ts +0 -40
- package/dist/local-recall/storage-reader.js +0 -157
- package/dist/local-recall/thinking-extractor.d.ts +0 -16
- package/dist/local-recall/thinking-extractor.js +0 -132
- package/dist/local-recall/types.d.ts +0 -152
- package/dist/local-recall/types.js +0 -7
- package/dist/local-recall/vector/embedding-provider.d.ts +0 -37
- package/dist/local-recall/vector/embedding-provider.js +0 -184
- package/dist/local-recall/vector/orama-index.d.ts +0 -39
- package/dist/local-recall/vector/orama-index.js +0 -408
- package/dist/local-recall/vector/types.d.ts +0 -33
- package/dist/local-recall/vector/types.js +0 -1
- package/dist/output.test.d.ts +0 -8
- package/dist/output.test.js +0 -205
- package/dist/plugins/ff-reviews-delete-plugin.d.ts +0 -2
- package/dist/plugins/ff-reviews-delete-plugin.js +0 -32
- package/dist/quality-gate-config.test.d.ts +0 -9
- package/dist/quality-gate-config.test.js +0 -164
- package/dist/stop-quality-gate.test.d.ts +0 -8
- package/dist/stop-quality-gate.test.js +0 -549
package/agents/building.md
CHANGED
|
@@ -76,18 +76,21 @@ At the start of EVERY building task:
|
|
|
76
76
|
|
|
77
77
|
To prevent conflicts and ensure a clean state, you MUST use git worktrees for your implementation:
|
|
78
78
|
|
|
79
|
-
1. **Create Worktree:** Before starting code modifications, create a dedicated worktree outside
|
|
79
|
+
1. **Create Worktree:** Before starting code modifications, create a dedicated worktree in a writable root (avoid `../` paths that may resolve outside CI workspace mounts):
|
|
80
80
|
```bash
|
|
81
|
-
|
|
81
|
+
WORKTREE_ROOT="${FF_WORKTREE_ROOT:-$PWD/.feature-factory/worktrees}"
|
|
82
|
+
mkdir -p "$WORKTREE_ROOT"
|
|
83
|
+
WORKTREE_PATH="$WORKTREE_ROOT/ff-build-{UUID}"
|
|
84
|
+
git worktree add "$WORKTREE_PATH" -b "feature/ff-build-{UUID}"
|
|
82
85
|
```
|
|
83
86
|
2. **Use the Worktree:**
|
|
84
|
-
- When using the `bash` tool, always set the `workdir` parameter to the absolute path
|
|
85
|
-
- When using `edit`, `write`, or `read` tools, ensure the `filePath` points to
|
|
87
|
+
- When using the `bash` tool, always set the `workdir` parameter to the absolute path in `$WORKTREE_PATH`.
|
|
88
|
+
- When using `edit`, `write`, or `read` tools, ensure the `filePath` points to files inside `$WORKTREE_PATH`.
|
|
86
89
|
3. **Commit & Push:** Commit your changes and push the branch from within the worktree.
|
|
87
90
|
4. **Cleanup:** After your work is pushed, remove the worktree:
|
|
88
91
|
```bash
|
|
89
|
-
git worktree remove
|
|
90
|
-
git branch -D feature/ff-build-{UUID}
|
|
92
|
+
git worktree remove "$WORKTREE_PATH" --force
|
|
93
|
+
git branch -D "feature/ff-build-{UUID}"
|
|
91
94
|
```
|
|
92
95
|
|
|
93
96
|
## File Management Tools
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.4",
|
|
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",
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"bin": {
|
|
11
|
-
"ff-deploy": "
|
|
11
|
+
"ff-deploy": "bin/ff-deploy.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
package/dist/discovery.test.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for discovery module
|
|
3
|
-
*
|
|
4
|
-
* Tests focus on the pure function:
|
|
5
|
-
* - buildRunCommand: builds package manager-specific run commands
|
|
6
|
-
*
|
|
7
|
-
* Note: Most functions in discovery.ts require shell access ($) so they're
|
|
8
|
-
* integration-tested elsewhere. This file tests the pure utility functions.
|
|
9
|
-
*/
|
|
10
|
-
export {};
|
package/dist/discovery.test.js
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for discovery module
|
|
3
|
-
*
|
|
4
|
-
* Tests focus on the pure function:
|
|
5
|
-
* - buildRunCommand: builds package manager-specific run commands
|
|
6
|
-
*
|
|
7
|
-
* Note: Most functions in discovery.ts require shell access ($) so they're
|
|
8
|
-
* integration-tested elsewhere. This file tests the pure utility functions.
|
|
9
|
-
*/
|
|
10
|
-
// Re-implement buildRunCommand for testing since it's not exported
|
|
11
|
-
function buildRunCommand(pm, script) {
|
|
12
|
-
switch (pm) {
|
|
13
|
-
case 'pnpm':
|
|
14
|
-
return `pnpm -s run ${script}`;
|
|
15
|
-
case 'bun':
|
|
16
|
-
return `bun run ${script}`;
|
|
17
|
-
case 'yarn':
|
|
18
|
-
return `yarn -s ${script}`;
|
|
19
|
-
case 'npm':
|
|
20
|
-
return `npm run -s ${script}`;
|
|
21
|
-
default:
|
|
22
|
-
return `npm run -s ${script}`;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
describe('buildRunCommand', () => {
|
|
26
|
-
describe('pnpm', () => {
|
|
27
|
-
it('should build correct pnpm command', () => {
|
|
28
|
-
expect(buildRunCommand('pnpm', 'lint')).toBe('pnpm -s run lint');
|
|
29
|
-
});
|
|
30
|
-
it('should build correct pnpm command for build script', () => {
|
|
31
|
-
expect(buildRunCommand('pnpm', 'build')).toBe('pnpm -s run build');
|
|
32
|
-
});
|
|
33
|
-
it('should build correct pnpm command for test script', () => {
|
|
34
|
-
expect(buildRunCommand('pnpm', 'test')).toBe('pnpm -s run test');
|
|
35
|
-
});
|
|
36
|
-
it('should handle scripts with colons', () => {
|
|
37
|
-
expect(buildRunCommand('pnpm', 'lint:ci')).toBe('pnpm -s run lint:ci');
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
describe('bun', () => {
|
|
41
|
-
it('should build correct bun command', () => {
|
|
42
|
-
expect(buildRunCommand('bun', 'lint')).toBe('bun run lint');
|
|
43
|
-
});
|
|
44
|
-
it('should build correct bun command for build script', () => {
|
|
45
|
-
expect(buildRunCommand('bun', 'build')).toBe('bun run build');
|
|
46
|
-
});
|
|
47
|
-
it('should build correct bun command for test script', () => {
|
|
48
|
-
expect(buildRunCommand('bun', 'test')).toBe('bun run test');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
describe('yarn', () => {
|
|
52
|
-
it('should build correct yarn command', () => {
|
|
53
|
-
expect(buildRunCommand('yarn', 'lint')).toBe('yarn -s lint');
|
|
54
|
-
});
|
|
55
|
-
it('should build correct yarn command for build script', () => {
|
|
56
|
-
expect(buildRunCommand('yarn', 'build')).toBe('yarn -s build');
|
|
57
|
-
});
|
|
58
|
-
it('should build correct yarn command for test script', () => {
|
|
59
|
-
expect(buildRunCommand('yarn', 'test')).toBe('yarn -s test');
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
describe('npm', () => {
|
|
63
|
-
it('should build correct npm command', () => {
|
|
64
|
-
expect(buildRunCommand('npm', 'lint')).toBe('npm run -s lint');
|
|
65
|
-
});
|
|
66
|
-
it('should build correct npm command for build script', () => {
|
|
67
|
-
expect(buildRunCommand('npm', 'build')).toBe('npm run -s build');
|
|
68
|
-
});
|
|
69
|
-
it('should build correct npm command for test script', () => {
|
|
70
|
-
expect(buildRunCommand('npm', 'test')).toBe('npm run -s test');
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
describe('edge cases', () => {
|
|
74
|
-
it('should handle script names with hyphens', () => {
|
|
75
|
-
expect(buildRunCommand('npm', 'type-check')).toBe('npm run -s type-check');
|
|
76
|
-
});
|
|
77
|
-
it('should handle script names with underscores', () => {
|
|
78
|
-
expect(buildRunCommand('pnpm', 'lint_fix')).toBe('pnpm -s run lint_fix');
|
|
79
|
-
});
|
|
80
|
-
it('should handle long script names', () => {
|
|
81
|
-
expect(buildRunCommand('yarn', 'test:unit:coverage')).toBe('yarn -s test:unit:coverage');
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
/**
|
|
86
|
-
* PackageManager type validation tests
|
|
87
|
-
* These ensure the type constraints are working correctly
|
|
88
|
-
*/
|
|
89
|
-
describe('PackageManager type', () => {
|
|
90
|
-
it('should accept valid package manager values', () => {
|
|
91
|
-
const validManagers = ['pnpm', 'bun', 'yarn', 'npm'];
|
|
92
|
-
validManagers.forEach((pm) => {
|
|
93
|
-
expect(['pnpm', 'bun', 'yarn', 'npm']).toContain(pm);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
export {};
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { type ExtractionStats } from './daemon.js';
|
|
2
|
-
import { OramaMemoryIndex } from './vector/orama-index.js';
|
|
3
|
-
export interface DaemonRunReport {
|
|
4
|
-
fullRebuild: boolean;
|
|
5
|
-
extraction: ExtractionStats | null;
|
|
6
|
-
upserted: number;
|
|
7
|
-
removed: number;
|
|
8
|
-
documents: number;
|
|
9
|
-
durationMs: number;
|
|
10
|
-
completedAt: string;
|
|
11
|
-
}
|
|
12
|
-
export interface DaemonStatus {
|
|
13
|
-
running: boolean;
|
|
14
|
-
processing: boolean;
|
|
15
|
-
intervalMs: number;
|
|
16
|
-
pending: boolean;
|
|
17
|
-
pendingReason: string | null;
|
|
18
|
-
lastRun: DaemonRunReport | null;
|
|
19
|
-
lastError: string | null;
|
|
20
|
-
}
|
|
21
|
-
interface ControllerOptions {
|
|
22
|
-
directory: string;
|
|
23
|
-
index: OramaMemoryIndex;
|
|
24
|
-
intervalMs?: number;
|
|
25
|
-
extractionEnabled?: boolean;
|
|
26
|
-
}
|
|
27
|
-
export declare class LocalRecallDaemonController {
|
|
28
|
-
private readonly directory;
|
|
29
|
-
private readonly index;
|
|
30
|
-
private readonly extractionEnabled;
|
|
31
|
-
private intervalMs;
|
|
32
|
-
private timer;
|
|
33
|
-
private running;
|
|
34
|
-
private processing;
|
|
35
|
-
private pending;
|
|
36
|
-
private pendingFullRebuild;
|
|
37
|
-
private pendingReason;
|
|
38
|
-
private lastRun;
|
|
39
|
-
private lastError;
|
|
40
|
-
constructor(options: ControllerOptions);
|
|
41
|
-
start(intervalMs?: number): DaemonStatus;
|
|
42
|
-
stop(): DaemonStatus;
|
|
43
|
-
requestRun(reason?: string, fullRebuild?: boolean): void;
|
|
44
|
-
runNow(reason?: string, fullRebuild?: boolean): Promise<DaemonStatus>;
|
|
45
|
-
rebuild(): Promise<DaemonStatus>;
|
|
46
|
-
getStatus(): DaemonStatus;
|
|
47
|
-
private runLoop;
|
|
48
|
-
private runCycle;
|
|
49
|
-
private waitForIdle;
|
|
50
|
-
}
|
|
51
|
-
export {};
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { runExtraction } from './daemon.js';
|
|
2
|
-
import { computeMemoryHash, readIndexState, writeIndexState } from './index-state.js';
|
|
3
|
-
import { listAllMemories } from './memory-service.js';
|
|
4
|
-
const DEFAULT_INTERVAL_MS = 15_000;
|
|
5
|
-
export class LocalRecallDaemonController {
|
|
6
|
-
directory;
|
|
7
|
-
index;
|
|
8
|
-
extractionEnabled;
|
|
9
|
-
intervalMs;
|
|
10
|
-
timer = null;
|
|
11
|
-
running = false;
|
|
12
|
-
processing = false;
|
|
13
|
-
pending = false;
|
|
14
|
-
pendingFullRebuild = false;
|
|
15
|
-
pendingReason = null;
|
|
16
|
-
lastRun = null;
|
|
17
|
-
lastError = null;
|
|
18
|
-
constructor(options) {
|
|
19
|
-
this.directory = options.directory;
|
|
20
|
-
this.index = options.index;
|
|
21
|
-
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
22
|
-
this.extractionEnabled = options.extractionEnabled ?? true;
|
|
23
|
-
}
|
|
24
|
-
start(intervalMs) {
|
|
25
|
-
if (intervalMs && intervalMs > 0) {
|
|
26
|
-
this.intervalMs = intervalMs;
|
|
27
|
-
}
|
|
28
|
-
if (this.running) {
|
|
29
|
-
return this.getStatus();
|
|
30
|
-
}
|
|
31
|
-
this.running = true;
|
|
32
|
-
this.timer = setInterval(() => {
|
|
33
|
-
this.requestRun('interval');
|
|
34
|
-
}, this.intervalMs);
|
|
35
|
-
this.requestRun('startup');
|
|
36
|
-
return this.getStatus();
|
|
37
|
-
}
|
|
38
|
-
stop() {
|
|
39
|
-
this.running = false;
|
|
40
|
-
if (this.timer) {
|
|
41
|
-
clearInterval(this.timer);
|
|
42
|
-
this.timer = null;
|
|
43
|
-
}
|
|
44
|
-
return this.getStatus();
|
|
45
|
-
}
|
|
46
|
-
requestRun(reason = 'manual', fullRebuild = false) {
|
|
47
|
-
this.pending = true;
|
|
48
|
-
this.pendingReason = reason;
|
|
49
|
-
if (fullRebuild) {
|
|
50
|
-
this.pendingFullRebuild = true;
|
|
51
|
-
}
|
|
52
|
-
void this.runLoop();
|
|
53
|
-
}
|
|
54
|
-
async runNow(reason = 'manual', fullRebuild = false) {
|
|
55
|
-
this.requestRun(reason, fullRebuild);
|
|
56
|
-
await this.waitForIdle();
|
|
57
|
-
return this.getStatus();
|
|
58
|
-
}
|
|
59
|
-
async rebuild() {
|
|
60
|
-
return this.runNow('rebuild', true);
|
|
61
|
-
}
|
|
62
|
-
getStatus() {
|
|
63
|
-
return {
|
|
64
|
-
running: this.running,
|
|
65
|
-
processing: this.processing,
|
|
66
|
-
intervalMs: this.intervalMs,
|
|
67
|
-
pending: this.pending,
|
|
68
|
-
pendingReason: this.pendingReason,
|
|
69
|
-
lastRun: this.lastRun,
|
|
70
|
-
lastError: this.lastError,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
async runLoop() {
|
|
74
|
-
if (this.processing) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
this.processing = true;
|
|
78
|
-
try {
|
|
79
|
-
while (this.pending) {
|
|
80
|
-
const fullRebuild = this.pendingFullRebuild;
|
|
81
|
-
this.pending = false;
|
|
82
|
-
this.pendingFullRebuild = false;
|
|
83
|
-
this.pendingReason = null;
|
|
84
|
-
await this.runCycle(fullRebuild);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
this.processing = false;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
async runCycle(fullRebuild) {
|
|
92
|
-
const startedAt = Date.now();
|
|
93
|
-
this.lastError = null;
|
|
94
|
-
try {
|
|
95
|
-
const extraction = this.extractionEnabled ? await runExtraction(this.directory) : null;
|
|
96
|
-
const allMemories = await listAllMemories(this.directory);
|
|
97
|
-
const now = Date.now();
|
|
98
|
-
let upserted = 0;
|
|
99
|
-
let removed = 0;
|
|
100
|
-
if (fullRebuild) {
|
|
101
|
-
upserted = await this.index.rebuild(allMemories);
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
const previousState = await readIndexState(this.directory);
|
|
105
|
-
const nextEntries = {};
|
|
106
|
-
const changed = [];
|
|
107
|
-
for (const memory of allMemories) {
|
|
108
|
-
const hash = computeMemoryHash(memory);
|
|
109
|
-
nextEntries[memory.id] = {
|
|
110
|
-
hash,
|
|
111
|
-
updatedAt: now,
|
|
112
|
-
};
|
|
113
|
-
const previous = previousState.entries[memory.id];
|
|
114
|
-
if (!previous || previous.hash !== hash) {
|
|
115
|
-
changed.push(memory);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
const removedIDs = Object.keys(previousState.entries).filter((id) => !nextEntries[id]);
|
|
119
|
-
if (changed.length > 0) {
|
|
120
|
-
upserted = await this.index.upsertMemories(changed);
|
|
121
|
-
}
|
|
122
|
-
if (removedIDs.length > 0) {
|
|
123
|
-
removed = await this.index.removeMemories(removedIDs);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
await writeIndexState(this.directory, {
|
|
127
|
-
version: 1,
|
|
128
|
-
entries: Object.fromEntries(allMemories.map((memory) => [
|
|
129
|
-
memory.id,
|
|
130
|
-
{
|
|
131
|
-
hash: computeMemoryHash(memory),
|
|
132
|
-
updatedAt: now,
|
|
133
|
-
},
|
|
134
|
-
])),
|
|
135
|
-
updatedAt: now,
|
|
136
|
-
});
|
|
137
|
-
const documents = this.index.getStatus().documents;
|
|
138
|
-
this.lastRun = {
|
|
139
|
-
fullRebuild,
|
|
140
|
-
extraction,
|
|
141
|
-
upserted,
|
|
142
|
-
removed,
|
|
143
|
-
documents,
|
|
144
|
-
durationMs: Date.now() - startedAt,
|
|
145
|
-
completedAt: new Date().toISOString(),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
this.lastError = error instanceof Error ? error.message : String(error);
|
|
150
|
-
this.lastRun = {
|
|
151
|
-
fullRebuild,
|
|
152
|
-
extraction: null,
|
|
153
|
-
upserted: 0,
|
|
154
|
-
removed: 0,
|
|
155
|
-
documents: this.index.getStatus().documents,
|
|
156
|
-
durationMs: Date.now() - startedAt,
|
|
157
|
-
completedAt: new Date().toISOString(),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
async waitForIdle() {
|
|
162
|
-
while (this.processing || this.pending) {
|
|
163
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* daemon.ts — Background extraction daemon for local-recall.
|
|
3
|
-
*
|
|
4
|
-
* Scans OpenCode session storage for unprocessed assistant and thinking messages,
|
|
5
|
-
* runs the extraction pipeline (session + thinking extractors),
|
|
6
|
-
* and stores resulting memories with logical IDs and content-hash
|
|
7
|
-
* idempotency.
|
|
8
|
-
*
|
|
9
|
-
* Logical ID conventions:
|
|
10
|
-
* session memories → session-<sessionID>-<messageID>-<index>
|
|
11
|
-
* thinking memories → thinking-<sessionID>-<messageID>-<index>
|
|
12
|
-
*
|
|
13
|
-
* Idempotency is dual-layer:
|
|
14
|
-
* 1. Message-ID check (fast skip for already-processed messages)
|
|
15
|
-
* 2. Content-hash check (skips duplicate content across edits/replays)
|
|
16
|
-
*/
|
|
17
|
-
export interface ExtractionStats {
|
|
18
|
-
sessionsScanned: number;
|
|
19
|
-
messagesScanned: number;
|
|
20
|
-
messagesSkipped: number;
|
|
21
|
-
newMemories: number;
|
|
22
|
-
errors: string[];
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Run a full extraction pass for the project rooted at `directory`.
|
|
26
|
-
*
|
|
27
|
-
* 1. Find the OpenCode project matching `directory`
|
|
28
|
-
* 2. Load existing processed log for fast membership checks
|
|
29
|
-
* 3. Iterate sessions → messages (assistant + thinking)
|
|
30
|
-
* 4. Skip by message-ID *and* content-hash (dual idempotency)
|
|
31
|
-
* 5. Run session + thinking extractors
|
|
32
|
-
* 6. Assign logical IDs (session-<sid>-<msgid>-N / thinking-<sid>-<msgid>-N)
|
|
33
|
-
* 7. Store new memories & update processed log
|
|
34
|
-
*/
|
|
35
|
-
export declare function runExtraction(directory: string): Promise<ExtractionStats>;
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* daemon.ts — Background extraction daemon for local-recall.
|
|
3
|
-
*
|
|
4
|
-
* Scans OpenCode session storage for unprocessed assistant and thinking messages,
|
|
5
|
-
* runs the extraction pipeline (session + thinking extractors),
|
|
6
|
-
* and stores resulting memories with logical IDs and content-hash
|
|
7
|
-
* idempotency.
|
|
8
|
-
*
|
|
9
|
-
* Logical ID conventions:
|
|
10
|
-
* session memories → session-<sessionID>-<messageID>-<index>
|
|
11
|
-
* thinking memories → thinking-<sessionID>-<messageID>-<index>
|
|
12
|
-
*
|
|
13
|
-
* Idempotency is dual-layer:
|
|
14
|
-
* 1. Message-ID check (fast skip for already-processed messages)
|
|
15
|
-
* 2. Content-hash check (skips duplicate content across edits/replays)
|
|
16
|
-
*/
|
|
17
|
-
import * as fs from 'node:fs/promises';
|
|
18
|
-
import { findProject, listSessions, listMessages, listParts } from './storage-reader.js';
|
|
19
|
-
import { extractFromMessage } from './session-extractor.js';
|
|
20
|
-
import { extractThinkingFromMessage } from './thinking-extractor.js';
|
|
21
|
-
import { readProcessedLog, getProcessedMessageIDs, getProcessedHashes, markProcessed, contentHash, } from './processed-log.js';
|
|
22
|
-
import { getMemoriesDir, storeMemories } from './memory-service.js';
|
|
23
|
-
function getErrorMessage(error) {
|
|
24
|
-
return error instanceof Error ? error.message : String(error);
|
|
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
|
-
}
|
|
53
|
-
function recordFailure(stats, failure) {
|
|
54
|
-
let message;
|
|
55
|
-
switch (failure.scope) {
|
|
56
|
-
case 'project':
|
|
57
|
-
message = `No OpenCode project found for directory: ${failure.directory}`;
|
|
58
|
-
break;
|
|
59
|
-
case 'message':
|
|
60
|
-
message = `Error processing message ${failure.messageID}: ${getErrorMessage(failure.error)}`;
|
|
61
|
-
break;
|
|
62
|
-
case 'session':
|
|
63
|
-
message = `Error processing session ${failure.sessionID}: ${getErrorMessage(failure.error)}`;
|
|
64
|
-
break;
|
|
65
|
-
case 'extraction':
|
|
66
|
-
message = `Extraction failed: ${getErrorMessage(failure.error)}`;
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
stats.errors.push(message);
|
|
70
|
-
return message;
|
|
71
|
-
}
|
|
72
|
-
// ────────────────────────────────────────────────────────────
|
|
73
|
-
// Helpers
|
|
74
|
-
// ────────────────────────────────────────────────────────────
|
|
75
|
-
/**
|
|
76
|
-
* Build a deterministic content hash for a message based on its
|
|
77
|
-
* concatenated part text. This allows us to detect duplicate
|
|
78
|
-
* processing even when message IDs change.
|
|
79
|
-
*/
|
|
80
|
-
async function buildMessageContentHash(messageID) {
|
|
81
|
-
try {
|
|
82
|
-
const parts = await listParts(messageID);
|
|
83
|
-
const textParts = parts
|
|
84
|
-
.filter((p) => p.type === 'text' || p.type === 'reasoning' || p.type === 'thinking')
|
|
85
|
-
.map((p) => p.text ?? '')
|
|
86
|
-
.join('\n');
|
|
87
|
-
// Hash content only (no messageID) so dedupe works across ID changes
|
|
88
|
-
return contentHash(textParts);
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
// Fallback: hash the message ID itself (best effort)
|
|
92
|
-
return contentHash(messageID);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// ────────────────────────────────────────────────────────────
|
|
96
|
-
// Main extraction loop
|
|
97
|
-
// ────────────────────────────────────────────────────────────
|
|
98
|
-
/**
|
|
99
|
-
* Run a full extraction pass for the project rooted at `directory`.
|
|
100
|
-
*
|
|
101
|
-
* 1. Find the OpenCode project matching `directory`
|
|
102
|
-
* 2. Load existing processed log for fast membership checks
|
|
103
|
-
* 3. Iterate sessions → messages (assistant + thinking)
|
|
104
|
-
* 4. Skip by message-ID *and* content-hash (dual idempotency)
|
|
105
|
-
* 5. Run session + thinking extractors
|
|
106
|
-
* 6. Assign logical IDs (session-<sid>-<msgid>-N / thinking-<sid>-<msgid>-N)
|
|
107
|
-
* 7. Store new memories & update processed log
|
|
108
|
-
*/
|
|
109
|
-
export async function runExtraction(directory) {
|
|
110
|
-
const stats = {
|
|
111
|
-
sessionsScanned: 0,
|
|
112
|
-
messagesScanned: 0,
|
|
113
|
-
messagesSkipped: 0,
|
|
114
|
-
newMemories: 0,
|
|
115
|
-
errors: [],
|
|
116
|
-
};
|
|
117
|
-
try {
|
|
118
|
-
// Find project for this directory
|
|
119
|
-
const project = await findProject(directory);
|
|
120
|
-
if (!project) {
|
|
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
|
-
}
|
|
129
|
-
return stats;
|
|
130
|
-
}
|
|
131
|
-
// Ensure local-recall directories exist
|
|
132
|
-
await fs.mkdir(getMemoriesDir(directory), { recursive: true });
|
|
133
|
-
// Pre-load processed log for fast lookups
|
|
134
|
-
const existingLog = await readProcessedLog(directory);
|
|
135
|
-
const processedMsgIDs = getProcessedMessageIDs(existingLog);
|
|
136
|
-
const processedHashes = getProcessedHashes(existingLog);
|
|
137
|
-
// Iterate sessions
|
|
138
|
-
const sessions = await listSessions(project.id);
|
|
139
|
-
stats.sessionsScanned = sessions.length;
|
|
140
|
-
const newProcessedEntries = [];
|
|
141
|
-
const allNewMemories = [];
|
|
142
|
-
for (const session of sessions) {
|
|
143
|
-
try {
|
|
144
|
-
const messages = await listMessages(session.id);
|
|
145
|
-
for (const message of messages) {
|
|
146
|
-
stats.messagesScanned++;
|
|
147
|
-
// Only process assistant and thinking messages
|
|
148
|
-
if (message.role !== 'assistant' && message.role !== 'thinking') {
|
|
149
|
-
stats.messagesSkipped++;
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
// Layer 1: skip by message ID
|
|
153
|
-
if (processedMsgIDs.has(message.id)) {
|
|
154
|
-
stats.messagesSkipped++;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
// Layer 2: skip by content hash
|
|
158
|
-
const msgHash = await buildMessageContentHash(message.id);
|
|
159
|
-
if (processedHashes.has(msgHash)) {
|
|
160
|
-
stats.messagesSkipped++;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
try {
|
|
164
|
-
const input = {
|
|
165
|
-
sessionID: session.id,
|
|
166
|
-
messageID: message.id,
|
|
167
|
-
};
|
|
168
|
-
// Run both extractors in parallel
|
|
169
|
-
const [sessionResults, thinkingResults] = await Promise.all([
|
|
170
|
-
extractFromMessage(input),
|
|
171
|
-
extractThinkingFromMessage(input),
|
|
172
|
-
]);
|
|
173
|
-
// Convert ExtractionResult → Memory with logical IDs
|
|
174
|
-
const sessionMemories = sessionResults.map((r, idx) => ({
|
|
175
|
-
id: `session-${session.id}-${message.id}-${idx}`,
|
|
176
|
-
sessionID: r.sessionID,
|
|
177
|
-
messageID: r.messageID,
|
|
178
|
-
category: r.category,
|
|
179
|
-
title: r.title,
|
|
180
|
-
body: r.body,
|
|
181
|
-
tags: r.tags,
|
|
182
|
-
importance: r.importance,
|
|
183
|
-
createdAt: message.time?.created ?? Date.now(),
|
|
184
|
-
extractedBy: 'local-recall-daemon',
|
|
185
|
-
}));
|
|
186
|
-
const thinkingMemories = thinkingResults.map((r, idx) => ({
|
|
187
|
-
id: `thinking-${session.id}-${message.id}-${idx}`,
|
|
188
|
-
sessionID: r.sessionID,
|
|
189
|
-
messageID: r.messageID,
|
|
190
|
-
category: r.category,
|
|
191
|
-
title: r.title,
|
|
192
|
-
body: r.body,
|
|
193
|
-
tags: r.tags,
|
|
194
|
-
importance: r.importance,
|
|
195
|
-
createdAt: message.time?.created ?? Date.now(),
|
|
196
|
-
extractedBy: 'local-recall-daemon',
|
|
197
|
-
}));
|
|
198
|
-
const memories = [...sessionMemories, ...thinkingMemories];
|
|
199
|
-
allNewMemories.push(...memories);
|
|
200
|
-
// Add to fast-lookup sets so subsequent messages in this
|
|
201
|
-
// pass are also deduplicated
|
|
202
|
-
processedMsgIDs.add(message.id);
|
|
203
|
-
processedHashes.add(msgHash);
|
|
204
|
-
// Mark as processed with content hash
|
|
205
|
-
newProcessedEntries.push({
|
|
206
|
-
status: 'success',
|
|
207
|
-
messageID: message.id,
|
|
208
|
-
contentHash: msgHash,
|
|
209
|
-
processedAt: Date.now(),
|
|
210
|
-
memoriesCreated: memories.length,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
catch (err) {
|
|
214
|
-
recordFailure(stats, {
|
|
215
|
-
scope: 'message',
|
|
216
|
-
messageID: message.id,
|
|
217
|
-
error: err,
|
|
218
|
-
});
|
|
219
|
-
// Still mark as processed to avoid re-trying broken messages
|
|
220
|
-
newProcessedEntries.push({
|
|
221
|
-
status: 'failed',
|
|
222
|
-
messageID: message.id,
|
|
223
|
-
contentHash: msgHash,
|
|
224
|
-
processedAt: Date.now(),
|
|
225
|
-
memoriesCreated: 0,
|
|
226
|
-
failure: getErrorMessage(err),
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
const failure = {
|
|
233
|
-
scope: 'session',
|
|
234
|
-
sessionID: session.id,
|
|
235
|
-
error: err,
|
|
236
|
-
};
|
|
237
|
-
const failureMessage = recordFailure(stats, failure);
|
|
238
|
-
newProcessedEntries.push(toProcessedFailureEntry(failure, failureMessage));
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
// Batch store all new memories
|
|
242
|
-
if (allNewMemories.length > 0) {
|
|
243
|
-
await storeMemories(directory, allNewMemories);
|
|
244
|
-
stats.newMemories = allNewMemories.length;
|
|
245
|
-
}
|
|
246
|
-
// Batch update processed log
|
|
247
|
-
if (newProcessedEntries.length > 0) {
|
|
248
|
-
await markProcessed(directory, newProcessedEntries);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
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
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return stats;
|
|
262
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { Memory } from './types.js';
|
|
2
|
-
export interface IndexStateEntry {
|
|
3
|
-
hash: string;
|
|
4
|
-
updatedAt: number;
|
|
5
|
-
}
|
|
6
|
-
export interface IndexState {
|
|
7
|
-
version: 1;
|
|
8
|
-
entries: Record<string, IndexStateEntry>;
|
|
9
|
-
updatedAt: number;
|
|
10
|
-
}
|
|
11
|
-
export declare function getIndexStatePath(directory: string): string;
|
|
12
|
-
export declare function readIndexState(directory: string): Promise<IndexState>;
|
|
13
|
-
export declare function writeIndexState(directory: string, state: IndexState): Promise<void>;
|
|
14
|
-
export declare function computeMemoryHash(memory: Memory): string;
|