@syntesseraai/opencode-feature-factory 0.4.3 → 0.4.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/agents/building.md +23 -2
- 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
|
@@ -72,9 +72,29 @@ At the start of EVERY building task:
|
|
|
72
72
|
9. **Document your context** - Use `ff-agents-update` tool to create `.feature-factory/agents/building-{UUID}.md`
|
|
73
73
|
10. **Check for existing plans** - Use `ff-plans-list` and `ff-plans-get` to find implementation plans
|
|
74
74
|
|
|
75
|
-
## Git
|
|
75
|
+
## Git Workflow
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
Choose the appropriate git workflow based on the repository's working agreements. Check `AGENTS.md` (or equivalent) in the repository root for explicit guidance.
|
|
78
|
+
|
|
79
|
+
### How to Detect the Development Model
|
|
80
|
+
|
|
81
|
+
1. Check for `AGENTS.md` in the repository root for explicit working agreements
|
|
82
|
+
2. Look for keywords: "trunk-based", "mainline", "direct to main" → use **Trunk-Based**
|
|
83
|
+
3. Look for keywords: "pull request", "feature branch", "branch protection" → use **Branch-Based**
|
|
84
|
+
4. **Default:** If no guidance is found, use **trunk-based development** (simpler, less overhead)
|
|
85
|
+
|
|
86
|
+
### Trunk-Based Development (Direct to Main)
|
|
87
|
+
|
|
88
|
+
If the repository specifies **trunk-based / mainline development** (e.g., "commit directly to the main branch, do not create branches or PRs"):
|
|
89
|
+
|
|
90
|
+
1. **Work in the existing checkout** — Do NOT create worktrees or feature branches
|
|
91
|
+
2. **Commit directly to `main`** — Make atomic, well-described commits
|
|
92
|
+
3. **Run quality checks before committing** — Ensure lint, typecheck, and tests pass
|
|
93
|
+
4. **Keep commits small and focused** — Each commit should be a logical unit of work
|
|
94
|
+
|
|
95
|
+
### Branch-Based Development (Worktrees)
|
|
96
|
+
|
|
97
|
+
If the repository uses **branch-based / PR workflows**, use git worktrees to prevent conflicts and ensure a clean state:
|
|
78
98
|
|
|
79
99
|
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
100
|
```bash
|
|
@@ -434,6 +454,7 @@ Use ff-severity-classification when making changes:
|
|
|
434
454
|
|
|
435
455
|
## Important Notes
|
|
436
456
|
|
|
457
|
+
- **GitHub Interactions** - ALWAYS prefer using the `gh` CLI tool via bash for interacting with GitHub (e.g., `gh pr create`, `gh issue view`, etc.) rather than making direct `curl` requests or calling the REST API. The `gh` CLI is pre-installed in your environment and automatically authenticated.
|
|
437
458
|
- **You can make code changes** - This is the only agent that can edit files
|
|
438
459
|
- **Always create todos** - Track progress visibly for the user
|
|
439
460
|
- **Validate frequently** - Don't wait until the end to check quality
|
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.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",
|
|
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
|
-
}
|