@syntesseraai/opencode-feature-factory 0.2.44 → 0.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/agents/building.md +13 -14
- package/agents/ff-acceptance.md +12 -15
- package/agents/ff-research.md +12 -16
- package/agents/ff-review.md +12 -15
- package/agents/ff-security.md +12 -15
- package/agents/ff-validate.md +12 -15
- package/agents/ff-well-architected.md +12 -15
- package/agents/planning.md +12 -24
- package/agents/reviewing.md +12 -24
- package/dist/index.js +7 -7
- package/dist/local-recall/daemon.d.ts +35 -0
- package/dist/local-recall/daemon.js +188 -0
- package/dist/local-recall/index.d.ts +14 -0
- package/dist/local-recall/index.js +20 -0
- package/dist/local-recall/mcp-server.d.ts +38 -0
- package/dist/local-recall/mcp-server.js +71 -0
- package/dist/local-recall/mcp-tools.d.ts +90 -0
- package/dist/local-recall/mcp-tools.js +162 -0
- package/dist/local-recall/memory-service.d.ts +31 -0
- package/dist/local-recall/memory-service.js +156 -0
- package/dist/local-recall/model-router.d.ts +23 -0
- package/dist/local-recall/model-router.js +41 -0
- package/dist/local-recall/processed-log.d.ts +41 -0
- package/dist/local-recall/processed-log.js +82 -0
- package/dist/local-recall/session-extractor.d.ts +19 -0
- package/dist/local-recall/session-extractor.js +172 -0
- package/dist/local-recall/storage-reader.d.ts +40 -0
- package/dist/local-recall/storage-reader.js +147 -0
- package/dist/local-recall/thinking-extractor.d.ts +16 -0
- package/dist/local-recall/thinking-extractor.js +132 -0
- package/dist/local-recall/types.d.ts +129 -0
- package/dist/local-recall/types.js +7 -0
- package/package.json +4 -4
- package/skills/ff-learning/SKILL.md +166 -689
- package/dist/learning/memory-get.d.ts +0 -24
- package/dist/learning/memory-get.js +0 -155
- package/dist/learning/memory-search.d.ts +0 -20
- package/dist/learning/memory-search.js +0 -193
- package/dist/learning/memory-store.d.ts +0 -20
- package/dist/learning/memory-store.js +0 -85
- package/dist/plugins/ff-learning-get-plugin.d.ts +0 -2
- package/dist/plugins/ff-learning-get-plugin.js +0 -55
- package/dist/plugins/ff-learning-search-plugin.d.ts +0 -2
- package/dist/plugins/ff-learning-search-plugin.js +0 -65
- package/dist/plugins/ff-learning-store-plugin.d.ts +0 -2
- package/dist/plugins/ff-learning-store-plugin.js +0 -70
- package/skills/ff-computer-use/SKILL.md +0 -473
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server.ts — MCP server lifecycle for local-recall.
|
|
3
|
+
*
|
|
4
|
+
* Manages the local-recall extraction daemon lifecycle within the
|
|
5
|
+
* OpenCode plugin. Provides init / shutdown hooks and exposes
|
|
6
|
+
* the extraction trigger for on-demand use.
|
|
7
|
+
*/
|
|
8
|
+
import { type ExtractionStats } from './daemon.js';
|
|
9
|
+
/**
|
|
10
|
+
* Initialize the local-recall system for a project directory.
|
|
11
|
+
* Should be called once during plugin startup.
|
|
12
|
+
*/
|
|
13
|
+
export declare function initLocalRecall(directory: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Check if local-recall has been initialized.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isInitialized(): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Get the project directory local-recall is bound to.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getDirectory(): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Trigger an extraction pass. Can be called on-demand (e.g. from
|
|
24
|
+
* the ff-learning-store tool) or at startup.
|
|
25
|
+
*
|
|
26
|
+
* Safe to call multiple times — the processed-log prevents
|
|
27
|
+
* duplicate extraction.
|
|
28
|
+
*/
|
|
29
|
+
export declare function triggerExtraction(): Promise<ExtractionStats | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Get the results of the last extraction pass.
|
|
32
|
+
*/
|
|
33
|
+
export declare function getLastExtractionStats(): ExtractionStats | null;
|
|
34
|
+
/**
|
|
35
|
+
* Shutdown the local-recall system. Currently a no-op but
|
|
36
|
+
* provided for lifecycle symmetry and future cleanup needs.
|
|
37
|
+
*/
|
|
38
|
+
export declare function shutdownLocalRecall(): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server.ts — MCP server lifecycle for local-recall.
|
|
3
|
+
*
|
|
4
|
+
* Manages the local-recall extraction daemon lifecycle within the
|
|
5
|
+
* OpenCode plugin. Provides init / shutdown hooks and exposes
|
|
6
|
+
* the extraction trigger for on-demand use.
|
|
7
|
+
*/
|
|
8
|
+
import { runExtraction } from './daemon.js';
|
|
9
|
+
// ────────────────────────────────────────────────────────────
|
|
10
|
+
// State
|
|
11
|
+
// ────────────────────────────────────────────────────────────
|
|
12
|
+
let _initialized = false;
|
|
13
|
+
let _directory = null;
|
|
14
|
+
let _lastExtraction = null;
|
|
15
|
+
// ────────────────────────────────────────────────────────────
|
|
16
|
+
// Public API
|
|
17
|
+
// ────────────────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Initialize the local-recall system for a project directory.
|
|
20
|
+
* Should be called once during plugin startup.
|
|
21
|
+
*/
|
|
22
|
+
export function initLocalRecall(directory) {
|
|
23
|
+
_directory = directory;
|
|
24
|
+
_initialized = true;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if local-recall has been initialized.
|
|
28
|
+
*/
|
|
29
|
+
export function isInitialized() {
|
|
30
|
+
return _initialized;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get the project directory local-recall is bound to.
|
|
34
|
+
*/
|
|
35
|
+
export function getDirectory() {
|
|
36
|
+
return _directory;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Trigger an extraction pass. Can be called on-demand (e.g. from
|
|
40
|
+
* the ff-learning-store tool) or at startup.
|
|
41
|
+
*
|
|
42
|
+
* Safe to call multiple times — the processed-log prevents
|
|
43
|
+
* duplicate extraction.
|
|
44
|
+
*/
|
|
45
|
+
export async function triggerExtraction() {
|
|
46
|
+
if (!_initialized || !_directory) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
_lastExtraction = await runExtraction(_directory);
|
|
51
|
+
return _lastExtraction;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the results of the last extraction pass.
|
|
59
|
+
*/
|
|
60
|
+
export function getLastExtractionStats() {
|
|
61
|
+
return _lastExtraction;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Shutdown the local-recall system. Currently a no-op but
|
|
65
|
+
* provided for lifecycle symmetry and future cleanup needs.
|
|
66
|
+
*/
|
|
67
|
+
export function shutdownLocalRecall() {
|
|
68
|
+
_initialized = false;
|
|
69
|
+
_directory = null;
|
|
70
|
+
_lastExtraction = null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools.ts — OpenCode plugin tool definitions for local-recall.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy ff-learning-{store,search,get} plugins with
|
|
5
|
+
* MCP-backed tools that read from OpenCode's session storage and
|
|
6
|
+
* expose the local-recall memory system.
|
|
7
|
+
*
|
|
8
|
+
* Public contract uses the native MemoryCategory taxonomy
|
|
9
|
+
* (pattern, decision, debugging, preference, context, procedure).
|
|
10
|
+
* The public contract exposes only category-based MCP tools.
|
|
11
|
+
*/
|
|
12
|
+
import type { ToolContext } from '@opencode-ai/plugin/tool';
|
|
13
|
+
export declare const createLearningStoreTool: () => {
|
|
14
|
+
description: string;
|
|
15
|
+
args: {
|
|
16
|
+
title: import("zod").ZodString;
|
|
17
|
+
description: import("zod").ZodString;
|
|
18
|
+
category: import("zod").ZodEnum<{
|
|
19
|
+
pattern: "pattern";
|
|
20
|
+
decision: "decision";
|
|
21
|
+
debugging: "debugging";
|
|
22
|
+
preference: "preference";
|
|
23
|
+
context: "context";
|
|
24
|
+
procedure: "procedure";
|
|
25
|
+
}>;
|
|
26
|
+
tags: import("zod").ZodArray<import("zod").ZodString>;
|
|
27
|
+
importance: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
28
|
+
content: import("zod").ZodOptional<import("zod").ZodString>;
|
|
29
|
+
source: import("zod").ZodOptional<import("zod").ZodEnum<{
|
|
30
|
+
conversation: "conversation";
|
|
31
|
+
research: "research";
|
|
32
|
+
implementation: "implementation";
|
|
33
|
+
review: "review";
|
|
34
|
+
}>>;
|
|
35
|
+
relatedMemories: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
36
|
+
context: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
37
|
+
project: import("zod").ZodOptional<import("zod").ZodString>;
|
|
38
|
+
task: import("zod").ZodOptional<import("zod").ZodString>;
|
|
39
|
+
files: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
40
|
+
}, import("zod/v4/core").$strip>>;
|
|
41
|
+
};
|
|
42
|
+
execute(args: {
|
|
43
|
+
title: string;
|
|
44
|
+
description: string;
|
|
45
|
+
category: "pattern" | "decision" | "debugging" | "preference" | "context" | "procedure";
|
|
46
|
+
tags: string[];
|
|
47
|
+
importance?: number | undefined;
|
|
48
|
+
content?: string | undefined;
|
|
49
|
+
source?: "conversation" | "research" | "implementation" | "review" | undefined;
|
|
50
|
+
relatedMemories?: string[] | undefined;
|
|
51
|
+
context?: {
|
|
52
|
+
project?: string | undefined;
|
|
53
|
+
task?: string | undefined;
|
|
54
|
+
files?: string[] | undefined;
|
|
55
|
+
} | undefined;
|
|
56
|
+
}, context: ToolContext): Promise<string>;
|
|
57
|
+
};
|
|
58
|
+
export declare const createLearningSearchTool: () => {
|
|
59
|
+
description: string;
|
|
60
|
+
args: {
|
|
61
|
+
query: import("zod").ZodString;
|
|
62
|
+
tags: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
63
|
+
category: import("zod").ZodOptional<import("zod").ZodEnum<{
|
|
64
|
+
pattern: "pattern";
|
|
65
|
+
decision: "decision";
|
|
66
|
+
debugging: "debugging";
|
|
67
|
+
preference: "preference";
|
|
68
|
+
context: "context";
|
|
69
|
+
procedure: "procedure";
|
|
70
|
+
}>>;
|
|
71
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
72
|
+
minImportance: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
73
|
+
};
|
|
74
|
+
execute(args: {
|
|
75
|
+
query: string;
|
|
76
|
+
tags?: string[] | undefined;
|
|
77
|
+
category?: "pattern" | "decision" | "debugging" | "preference" | "context" | "procedure" | undefined;
|
|
78
|
+
limit?: number | undefined;
|
|
79
|
+
minImportance?: number | undefined;
|
|
80
|
+
}, context: ToolContext): Promise<string>;
|
|
81
|
+
};
|
|
82
|
+
export declare const createLearningGetTool: () => {
|
|
83
|
+
description: string;
|
|
84
|
+
args: {
|
|
85
|
+
memoryId: import("zod").ZodString;
|
|
86
|
+
};
|
|
87
|
+
execute(args: {
|
|
88
|
+
memoryId: string;
|
|
89
|
+
}, context: ToolContext): Promise<string>;
|
|
90
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools.ts — OpenCode plugin tool definitions for local-recall.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy ff-learning-{store,search,get} plugins with
|
|
5
|
+
* MCP-backed tools that read from OpenCode's session storage and
|
|
6
|
+
* expose the local-recall memory system.
|
|
7
|
+
*
|
|
8
|
+
* Public contract uses the native MemoryCategory taxonomy
|
|
9
|
+
* (pattern, decision, debugging, preference, context, procedure).
|
|
10
|
+
* The public contract exposes only category-based MCP tools.
|
|
11
|
+
*/
|
|
12
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
13
|
+
import { searchMemories, getMemory } from './memory-service.js';
|
|
14
|
+
import { runExtraction } from './daemon.js';
|
|
15
|
+
// ────────────────────────────────────────────────────────────
|
|
16
|
+
// ff-learning-store → triggers extraction + explicit store
|
|
17
|
+
// ────────────────────────────────────────────────────────────
|
|
18
|
+
export const createLearningStoreTool = () => tool({
|
|
19
|
+
description: 'Store a new learning/memory. Creates a JSON memory file in .feature-factory/local-recall/memories/. ' +
|
|
20
|
+
'Also triggers extraction from OpenCode session history so that ' +
|
|
21
|
+
'new memories are automatically captured from recent conversations.',
|
|
22
|
+
args: {
|
|
23
|
+
title: tool.schema.string('Title for the memory'),
|
|
24
|
+
description: tool.schema.string('Brief description of the learning'),
|
|
25
|
+
category: tool.schema.enum(['pattern', 'decision', 'debugging', 'preference', 'context', 'procedure'], 'Category of memory: pattern (reusable code), decision (arch/design), debugging (error resolution), preference (user pref), context (domain knowledge), procedure (how-to)'),
|
|
26
|
+
tags: tool.schema.array(tool.schema.string(), 'Tags for categorization'),
|
|
27
|
+
importance: tool.schema.number('Importance score from 0 to 1').optional(),
|
|
28
|
+
content: tool.schema.string('Full content/body of the memory').optional(),
|
|
29
|
+
source: tool.schema.enum(['conversation', 'research', 'implementation', 'review']).optional(),
|
|
30
|
+
relatedMemories: tool.schema.array(tool.schema.string(), 'Related memory IDs').optional(),
|
|
31
|
+
context: tool.schema
|
|
32
|
+
.object({
|
|
33
|
+
project: tool.schema.string().optional(),
|
|
34
|
+
task: tool.schema.string().optional(),
|
|
35
|
+
files: tool.schema.array(tool.schema.string()).optional(),
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
},
|
|
39
|
+
async execute(args, toolCtx) {
|
|
40
|
+
try {
|
|
41
|
+
// Run extraction to ensure latest session data is captured
|
|
42
|
+
const stats = await runExtraction(toolCtx.directory);
|
|
43
|
+
// Also store the explicit memory from the agent
|
|
44
|
+
const { storeMemory } = await import('./memory-service.js');
|
|
45
|
+
const memory = {
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
sessionID: 'agent-explicit',
|
|
48
|
+
messageID: 'agent-explicit',
|
|
49
|
+
category: args.category,
|
|
50
|
+
title: args.title,
|
|
51
|
+
body: args.content ?? args.description,
|
|
52
|
+
tags: args.tags,
|
|
53
|
+
importance: args.importance ?? 0.7,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
extractedBy: 'agent-explicit',
|
|
56
|
+
};
|
|
57
|
+
const result = await storeMemory(toolCtx.directory, memory);
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
success: true,
|
|
60
|
+
memoryId: result.id,
|
|
61
|
+
extractionStats: {
|
|
62
|
+
sessionsScanned: stats.sessionsScanned,
|
|
63
|
+
newMemories: stats.newMemories,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return JSON.stringify({
|
|
69
|
+
success: false,
|
|
70
|
+
error: err instanceof Error ? err.message : String(err),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// ────────────────────────────────────────────────────────────
|
|
76
|
+
// ff-learning-search → searches local-recall memory store
|
|
77
|
+
// ────────────────────────────────────────────────────────────
|
|
78
|
+
export const createLearningSearchTool = () => tool({
|
|
79
|
+
description: 'Search for memories by query, tags, or category. Returns matching memory metadata sorted by relevance and importance. ' +
|
|
80
|
+
'Searches the local-recall memory store which is automatically populated from OpenCode session history.',
|
|
81
|
+
args: {
|
|
82
|
+
query: tool.schema.string('Search query to match against memory content'),
|
|
83
|
+
tags: tool.schema.array(tool.schema.string(), 'Filter by tags').optional(),
|
|
84
|
+
category: tool.schema
|
|
85
|
+
.enum(['pattern', 'decision', 'debugging', 'preference', 'context', 'procedure'])
|
|
86
|
+
.optional(),
|
|
87
|
+
limit: tool.schema.number('Max results (1-50, default 10)').optional(),
|
|
88
|
+
minImportance: tool.schema.number('Minimum importance 0-1').optional(),
|
|
89
|
+
},
|
|
90
|
+
async execute(args, toolCtx) {
|
|
91
|
+
try {
|
|
92
|
+
const results = await searchMemories(toolCtx.directory, {
|
|
93
|
+
query: args.query,
|
|
94
|
+
tags: args.tags,
|
|
95
|
+
category: args.category,
|
|
96
|
+
minImportance: args.minImportance,
|
|
97
|
+
limit: Math.min(Math.max(args.limit ?? 10, 1), 50),
|
|
98
|
+
});
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
count: results.length,
|
|
101
|
+
memories: results.map((r) => ({
|
|
102
|
+
id: r.id,
|
|
103
|
+
title: r.title,
|
|
104
|
+
category: r.category,
|
|
105
|
+
tags: r.tags,
|
|
106
|
+
importance: r.importance,
|
|
107
|
+
relevance: Math.round(r.relevance * 100) / 100,
|
|
108
|
+
createdAt: r.createdAt,
|
|
109
|
+
sessionID: r.sessionID,
|
|
110
|
+
})),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return JSON.stringify({
|
|
115
|
+
success: false,
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// ────────────────────────────────────────────────────────────
|
|
122
|
+
// ff-learning-get → retrieves a specific memory by ID
|
|
123
|
+
// ────────────────────────────────────────────────────────────
|
|
124
|
+
export const createLearningGetTool = () => tool({
|
|
125
|
+
description: 'Retrieve the full content of a specific memory by its unique ID. ' +
|
|
126
|
+
'Returns the complete memory including body text from the local-recall store.',
|
|
127
|
+
args: {
|
|
128
|
+
memoryId: tool.schema.string('Unique memory ID to retrieve'),
|
|
129
|
+
},
|
|
130
|
+
async execute(args, toolCtx) {
|
|
131
|
+
try {
|
|
132
|
+
const memory = await getMemory(toolCtx.directory, args.memoryId);
|
|
133
|
+
if (!memory) {
|
|
134
|
+
return JSON.stringify({
|
|
135
|
+
success: false,
|
|
136
|
+
error: `Memory not found: ${args.memoryId}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return JSON.stringify({
|
|
140
|
+
success: true,
|
|
141
|
+
memory: {
|
|
142
|
+
id: memory.id,
|
|
143
|
+
sessionID: memory.sessionID,
|
|
144
|
+
messageID: memory.messageID,
|
|
145
|
+
category: memory.category,
|
|
146
|
+
title: memory.title,
|
|
147
|
+
body: memory.body,
|
|
148
|
+
tags: memory.tags,
|
|
149
|
+
importance: memory.importance,
|
|
150
|
+
createdAt: memory.createdAt,
|
|
151
|
+
extractedBy: memory.extractedBy,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
return JSON.stringify({
|
|
157
|
+
success: false,
|
|
158
|
+
error: err instanceof Error ? err.message : String(err),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Service
|
|
3
|
+
*
|
|
4
|
+
* MCP-backed memory domain service that stores and retrieves
|
|
5
|
+
* extracted memories as JSON files in .feature-factory/local-recall/memories/
|
|
6
|
+
*/
|
|
7
|
+
import type { Memory, SearchCriteria, MemorySearchResult } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Persist a memory to disk.
|
|
10
|
+
*/
|
|
11
|
+
export declare function storeMemory(directory: string, memory: Memory): Promise<{
|
|
12
|
+
id: string;
|
|
13
|
+
filePath: string;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Persist multiple memories at once.
|
|
17
|
+
*/
|
|
18
|
+
export declare function storeMemories(directory: string, memories: Memory[]): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Read a single memory by ID.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getMemory(directory: string, memoryId: string): Promise<Memory | null>;
|
|
23
|
+
/**
|
|
24
|
+
* List all stored memories.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listAllMemories(directory: string): Promise<Memory[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Search memories by criteria.
|
|
29
|
+
* Returns results sorted by relevance * importance, limited to `limit`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function searchMemories(directory: string, criteria: SearchCriteria): Promise<MemorySearchResult[]>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Service
|
|
3
|
+
*
|
|
4
|
+
* MCP-backed memory domain service that stores and retrieves
|
|
5
|
+
* extracted memories as JSON files in .feature-factory/local-recall/memories/
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, readdir, mkdir } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
const { join, resolve } = path;
|
|
10
|
+
/** Only allow IDs that are safe for filenames: alphanumeric, hyphens, underscores. */
|
|
11
|
+
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
12
|
+
function getMemoriesDir(directory) {
|
|
13
|
+
return join(directory, '.feature-factory', 'local-recall', 'memories');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate a memory ID and resolve its file path with containment check.
|
|
17
|
+
* Throws if the ID is invalid or the resolved path escapes the memories directory.
|
|
18
|
+
*/
|
|
19
|
+
function resolveMemoryPath(memoriesDir, memoryId) {
|
|
20
|
+
if (!SAFE_ID_PATTERN.test(memoryId)) {
|
|
21
|
+
throw new Error(`Invalid memory ID: ${memoryId}`);
|
|
22
|
+
}
|
|
23
|
+
const resolved = resolve(memoriesDir, `${memoryId}.json`);
|
|
24
|
+
const canonicalDir = resolve(memoriesDir);
|
|
25
|
+
const relative = path.relative(canonicalDir, resolved);
|
|
26
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
27
|
+
throw new Error(`Memory ID escapes storage directory: ${memoryId}`);
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
// ── Store ───────────────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Persist a memory to disk.
|
|
34
|
+
*/
|
|
35
|
+
export async function storeMemory(directory, memory) {
|
|
36
|
+
const memoriesDir = getMemoriesDir(directory);
|
|
37
|
+
await mkdir(memoriesDir, { recursive: true });
|
|
38
|
+
const filePath = resolveMemoryPath(memoriesDir, memory.id);
|
|
39
|
+
await writeFile(filePath, JSON.stringify(memory, null, 2), 'utf-8');
|
|
40
|
+
return { id: memory.id, filePath };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Persist multiple memories at once.
|
|
44
|
+
*/
|
|
45
|
+
export async function storeMemories(directory, memories) {
|
|
46
|
+
for (const memory of memories) {
|
|
47
|
+
await storeMemory(directory, memory);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── Retrieve ────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Read a single memory by ID.
|
|
53
|
+
*/
|
|
54
|
+
export async function getMemory(directory, memoryId) {
|
|
55
|
+
try {
|
|
56
|
+
const filePath = resolveMemoryPath(getMemoriesDir(directory), memoryId);
|
|
57
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
58
|
+
return JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* List all stored memories.
|
|
66
|
+
*/
|
|
67
|
+
export async function listAllMemories(directory) {
|
|
68
|
+
const memoriesDir = getMemoriesDir(directory);
|
|
69
|
+
try {
|
|
70
|
+
const files = await readdir(memoriesDir);
|
|
71
|
+
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
72
|
+
const memories = [];
|
|
73
|
+
for (const file of jsonFiles) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = await readFile(join(memoriesDir, file), 'utf-8');
|
|
76
|
+
memories.push(JSON.parse(raw));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Skip malformed files
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return memories;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── Search ──────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Compute a simple relevance score between a query and a memory.
|
|
91
|
+
* Uses term-frequency matching on title, body, and tags.
|
|
92
|
+
*/
|
|
93
|
+
function computeRelevance(query, memory) {
|
|
94
|
+
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
95
|
+
if (queryTerms.length === 0)
|
|
96
|
+
return 0;
|
|
97
|
+
const searchable = [
|
|
98
|
+
memory.title.toLowerCase(),
|
|
99
|
+
memory.body.toLowerCase(),
|
|
100
|
+
...memory.tags.map((t) => t.toLowerCase()),
|
|
101
|
+
].join(' ');
|
|
102
|
+
let matchCount = 0;
|
|
103
|
+
for (const term of queryTerms) {
|
|
104
|
+
if (searchable.includes(term)) {
|
|
105
|
+
matchCount++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return matchCount / queryTerms.length;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Search memories by criteria.
|
|
112
|
+
* Returns results sorted by relevance * importance, limited to `limit`.
|
|
113
|
+
*/
|
|
114
|
+
export async function searchMemories(directory, criteria) {
|
|
115
|
+
const allMemories = await listAllMemories(directory);
|
|
116
|
+
const limit = criteria.limit ?? 10;
|
|
117
|
+
let filtered = allMemories;
|
|
118
|
+
// Filter by category
|
|
119
|
+
if (criteria.category) {
|
|
120
|
+
filtered = filtered.filter((m) => m.category === criteria.category);
|
|
121
|
+
}
|
|
122
|
+
// Filter by session
|
|
123
|
+
if (criteria.sessionID) {
|
|
124
|
+
filtered = filtered.filter((m) => m.sessionID === criteria.sessionID);
|
|
125
|
+
}
|
|
126
|
+
// Filter by minimum importance
|
|
127
|
+
if (criteria.minImportance !== undefined) {
|
|
128
|
+
filtered = filtered.filter((m) => m.importance >= criteria.minImportance);
|
|
129
|
+
}
|
|
130
|
+
// Filter by tags (any match)
|
|
131
|
+
if (criteria.tags && criteria.tags.length > 0) {
|
|
132
|
+
const tagSet = new Set(criteria.tags.map((t) => t.toLowerCase()));
|
|
133
|
+
filtered = filtered.filter((m) => m.tags.some((t) => tagSet.has(t.toLowerCase())));
|
|
134
|
+
}
|
|
135
|
+
// Score and rank
|
|
136
|
+
const scored = filtered.map((m) => {
|
|
137
|
+
const relevance = computeRelevance(criteria.query, m);
|
|
138
|
+
return {
|
|
139
|
+
id: m.id,
|
|
140
|
+
sessionID: m.sessionID,
|
|
141
|
+
category: m.category,
|
|
142
|
+
title: m.title,
|
|
143
|
+
tags: m.tags,
|
|
144
|
+
importance: m.importance,
|
|
145
|
+
createdAt: m.createdAt,
|
|
146
|
+
relevance,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
// Sort by combined score (relevance * importance)
|
|
150
|
+
scored.sort((a, b) => {
|
|
151
|
+
const scoreA = a.relevance * a.importance;
|
|
152
|
+
const scoreB = b.relevance * b.importance;
|
|
153
|
+
return scoreB - scoreA;
|
|
154
|
+
});
|
|
155
|
+
return scored.slice(0, limit);
|
|
156
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Router
|
|
3
|
+
*
|
|
4
|
+
* Determines which AI model to use for memory extraction.
|
|
5
|
+
* Maintains an allowlist of cheap/fast models suitable for extraction work.
|
|
6
|
+
*/
|
|
7
|
+
import type { AllowedModel } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Check if a model is in the extraction allowlist.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isAllowedModel(providerID: string, modelID: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Get the preferred extraction model from the allowlist.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getPreferredModel(): AllowedModel;
|
|
16
|
+
/**
|
|
17
|
+
* Get all allowed models sorted by priority.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getAllowedModels(): AllowedModel[];
|
|
20
|
+
/**
|
|
21
|
+
* Find the best allowed model given a set of available provider IDs.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectModel(availableProviders: string[]): AllowedModel | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Router
|
|
3
|
+
*
|
|
4
|
+
* Determines which AI model to use for memory extraction.
|
|
5
|
+
* Maintains an allowlist of cheap/fast models suitable for extraction work.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Allowlist of models eligible for extraction.
|
|
9
|
+
* Ordered by priority (lower = preferred).
|
|
10
|
+
*/
|
|
11
|
+
const ALLOWED_MODELS = [
|
|
12
|
+
{ providerID: 'anthropic', modelID: 'claude-haiku-4-5', priority: 1 },
|
|
13
|
+
{ providerID: 'openai', modelID: 'gpt-5-mini', priority: 2 },
|
|
14
|
+
{ providerID: 'moonshot', modelID: 'kimi-k2.5', priority: 3 },
|
|
15
|
+
{ providerID: 'google', modelID: 'gemini-2.0-flash', priority: 4 },
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Check if a model is in the extraction allowlist.
|
|
19
|
+
*/
|
|
20
|
+
export function isAllowedModel(providerID, modelID) {
|
|
21
|
+
return ALLOWED_MODELS.some((m) => m.providerID === providerID && m.modelID === modelID);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Get the preferred extraction model from the allowlist.
|
|
25
|
+
*/
|
|
26
|
+
export function getPreferredModel() {
|
|
27
|
+
return ALLOWED_MODELS[0];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get all allowed models sorted by priority.
|
|
31
|
+
*/
|
|
32
|
+
export function getAllowedModels() {
|
|
33
|
+
return [...ALLOWED_MODELS];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Find the best allowed model given a set of available provider IDs.
|
|
37
|
+
*/
|
|
38
|
+
export function selectModel(availableProviders) {
|
|
39
|
+
const providerSet = new Set(availableProviders);
|
|
40
|
+
return ALLOWED_MODELS.find((m) => providerSet.has(m.providerID)) ?? null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processed Log
|
|
3
|
+
*
|
|
4
|
+
* Tracks which messages have already been processed for memory extraction.
|
|
5
|
+
* Uses content-hash based idempotency: each processed entry stores a
|
|
6
|
+
* hash derived from the message content so re-extractions with identical
|
|
7
|
+
* content are skipped even if message IDs change.
|
|
8
|
+
*
|
|
9
|
+
* Stored as a JSON file at .feature-factory/local-recall/processed.json
|
|
10
|
+
*/
|
|
11
|
+
import type { ProcessedEntry } from './types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Create a SHA-256 content hash from one or more text fragments.
|
|
14
|
+
* Used to de-duplicate extraction across re-processes / message edits.
|
|
15
|
+
*/
|
|
16
|
+
export declare function contentHash(...fragments: string[]): string;
|
|
17
|
+
/**
|
|
18
|
+
* Read all processed entries from the log.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readProcessedLog(directory: string): Promise<ProcessedEntry[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Check if a specific message has already been processed (by message ID).
|
|
23
|
+
*/
|
|
24
|
+
export declare function isProcessed(directory: string, messageID: string): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Check if a content hash has already been processed.
|
|
27
|
+
* This catches duplicate content even if message IDs differ.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isContentProcessed(directory: string, hash: string): Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* Mark messages as processed by appending entries to the log.
|
|
32
|
+
*/
|
|
33
|
+
export declare function markProcessed(directory: string, entries: ProcessedEntry[]): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Get the set of already-processed message IDs for fast lookup.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getProcessedMessageIDs(log: ProcessedEntry[]): Set<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Get the set of already-processed content hashes for fast lookup.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getProcessedHashes(log: ProcessedEntry[]): Set<string>;
|