@kodrunhq/opencode-autopilot 1.4.0 → 1.6.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/assets/commands/brainstorm.md +7 -0
- package/assets/commands/stocktake.md +7 -0
- package/assets/commands/tdd.md +7 -0
- package/assets/commands/update-docs.md +7 -0
- package/assets/commands/write-plan.md +7 -0
- package/assets/skills/brainstorming/SKILL.md +295 -0
- package/assets/skills/code-review/SKILL.md +241 -0
- package/assets/skills/e2e-testing/SKILL.md +266 -0
- package/assets/skills/git-worktrees/SKILL.md +296 -0
- package/assets/skills/go-patterns/SKILL.md +240 -0
- package/assets/skills/plan-executing/SKILL.md +258 -0
- package/assets/skills/plan-writing/SKILL.md +278 -0
- package/assets/skills/python-patterns/SKILL.md +255 -0
- package/assets/skills/rust-patterns/SKILL.md +293 -0
- package/assets/skills/strategic-compaction/SKILL.md +217 -0
- package/assets/skills/systematic-debugging/SKILL.md +299 -0
- package/assets/skills/tdd-workflow/SKILL.md +311 -0
- package/assets/skills/typescript-patterns/SKILL.md +278 -0
- package/assets/skills/verification/SKILL.md +240 -0
- package/bin/configure-tui.ts +1 -1
- package/package.json +1 -1
- package/src/config.ts +76 -14
- package/src/index.ts +43 -2
- package/src/memory/capture.ts +205 -0
- package/src/memory/constants.ts +26 -0
- package/src/memory/database.ts +103 -0
- package/src/memory/decay.ts +94 -0
- package/src/memory/index.ts +24 -0
- package/src/memory/injector.ts +85 -0
- package/src/memory/project-key.ts +5 -0
- package/src/memory/repository.ts +217 -0
- package/src/memory/retrieval.ts +260 -0
- package/src/memory/schemas.ts +34 -0
- package/src/memory/types.ts +12 -0
- package/src/orchestrator/skill-injection.ts +38 -0
- package/src/review/sanitize.ts +1 -1
- package/src/skills/adaptive-injector.ts +122 -0
- package/src/skills/dependency-resolver.ts +88 -0
- package/src/skills/linter.ts +113 -0
- package/src/skills/loader.ts +88 -0
- package/src/templates/skill-template.ts +4 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/create-skill.ts +12 -0
- package/src/tools/memory-status.ts +164 -0
- package/src/tools/stocktake.ts +170 -0
- package/src/tools/update-docs.ts +116 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { createMemoryCaptureHandler, type MemoryCaptureDeps } from "./capture";
|
|
2
|
+
export * from "./constants";
|
|
3
|
+
export { closeMemoryDb, getMemoryDb, initMemoryDb } from "./database";
|
|
4
|
+
export { computeRelevanceScore, pruneStaleObservations } from "./decay";
|
|
5
|
+
export { createMemoryInjector, type MemoryInjectorConfig } from "./injector";
|
|
6
|
+
export { computeProjectKey } from "./project-key";
|
|
7
|
+
export {
|
|
8
|
+
deleteObservation,
|
|
9
|
+
getAllPreferences,
|
|
10
|
+
getObservationsByProject,
|
|
11
|
+
getProjectByPath,
|
|
12
|
+
insertObservation,
|
|
13
|
+
searchObservations,
|
|
14
|
+
updateAccessCount,
|
|
15
|
+
upsertPreference,
|
|
16
|
+
upsertProject,
|
|
17
|
+
} from "./repository";
|
|
18
|
+
export {
|
|
19
|
+
buildMemoryContext,
|
|
20
|
+
retrieveMemoryContext,
|
|
21
|
+
type ScoredObservation,
|
|
22
|
+
scoreAndRankObservations,
|
|
23
|
+
} from "./retrieval";
|
|
24
|
+
export * from "./types";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt injection for memory context.
|
|
3
|
+
*
|
|
4
|
+
* Creates a per-session cached injector that retrieves relevant memories
|
|
5
|
+
* and pushes them into the system prompt via output.system.
|
|
6
|
+
*
|
|
7
|
+
* Best-effort: all errors are silently caught to never break the session.
|
|
8
|
+
* Same pattern as skill-injection.ts.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
import { retrieveMemoryContext } from "./retrieval";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Configuration for creating a memory injector.
|
|
18
|
+
*/
|
|
19
|
+
export interface MemoryInjectorConfig {
|
|
20
|
+
readonly projectRoot: string;
|
|
21
|
+
readonly tokenBudget: number;
|
|
22
|
+
readonly halfLifeDays: number;
|
|
23
|
+
readonly getDb: () => Database;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Input shape matching the experimental.chat.system.transform hook signature.
|
|
28
|
+
*/
|
|
29
|
+
interface InjectorInput {
|
|
30
|
+
readonly sessionID?: string;
|
|
31
|
+
readonly model: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Output shape matching the experimental.chat.system.transform hook signature.
|
|
36
|
+
* system is mutable — the hook API expects callers to push into it.
|
|
37
|
+
*/
|
|
38
|
+
interface InjectorOutput {
|
|
39
|
+
system: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a memory injector function for the experimental.chat.system.transform hook.
|
|
44
|
+
*
|
|
45
|
+
* Returns an async function that:
|
|
46
|
+
* 1. Skips if no sessionID is provided
|
|
47
|
+
* 2. Returns cached context for known sessions
|
|
48
|
+
* 3. Retrieves and caches memory context for new sessions
|
|
49
|
+
* 4. Pushes non-empty context to output.system
|
|
50
|
+
*
|
|
51
|
+
* All errors are silently caught (best-effort, per D-24 and skill-injection pattern).
|
|
52
|
+
*/
|
|
53
|
+
export function createMemoryInjector(config: MemoryInjectorConfig) {
|
|
54
|
+
const cache = new Map<string, string>();
|
|
55
|
+
|
|
56
|
+
return async (input: InjectorInput, output: InjectorOutput): Promise<void> => {
|
|
57
|
+
try {
|
|
58
|
+
if (!input.sessionID) return;
|
|
59
|
+
|
|
60
|
+
const cached = cache.get(input.sessionID);
|
|
61
|
+
if (cached !== undefined) {
|
|
62
|
+
if (cached.length > 0) {
|
|
63
|
+
output.system.push(cached);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const db = config.getDb();
|
|
69
|
+
const context = retrieveMemoryContext(
|
|
70
|
+
config.projectRoot,
|
|
71
|
+
config.tokenBudget,
|
|
72
|
+
db,
|
|
73
|
+
config.halfLifeDays,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
cache.set(input.sessionID, context);
|
|
77
|
+
|
|
78
|
+
if (context.length > 0) {
|
|
79
|
+
output.system.push(context);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.warn("[opencode-autopilot] memory injection failed:", err);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { OBSERVATION_TYPES } from "./constants";
|
|
3
|
+
import { getMemoryDb } from "./database";
|
|
4
|
+
import { observationSchema, preferenceSchema, projectSchema } from "./schemas";
|
|
5
|
+
import type { Observation, ObservationType, Preference, Project } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Resolve optional db parameter to singleton fallback. */
|
|
8
|
+
function resolveDb(db?: Database): Database {
|
|
9
|
+
return db ?? getMemoryDb();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validate observation type at runtime. */
|
|
13
|
+
function parseObservationType(value: unknown): ObservationType {
|
|
14
|
+
if (typeof value === "string" && (OBSERVATION_TYPES as readonly string[]).includes(value)) {
|
|
15
|
+
return value as ObservationType;
|
|
16
|
+
}
|
|
17
|
+
return "context"; // safe fallback for corrupt/unknown types
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Map a snake_case DB row to camelCase Observation. */
|
|
21
|
+
function rowToObservation(row: Record<string, unknown>): Observation {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id as number,
|
|
24
|
+
projectId: (row.project_id as string) ?? null,
|
|
25
|
+
sessionId: row.session_id as string,
|
|
26
|
+
type: parseObservationType(row.type),
|
|
27
|
+
content: row.content as string,
|
|
28
|
+
summary: row.summary as string,
|
|
29
|
+
confidence: row.confidence as number,
|
|
30
|
+
accessCount: row.access_count as number,
|
|
31
|
+
createdAt: row.created_at as string,
|
|
32
|
+
lastAccessed: row.last_accessed as string,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map a snake_case DB row to camelCase Project. */
|
|
37
|
+
function rowToProject(row: Record<string, unknown>): Project {
|
|
38
|
+
return {
|
|
39
|
+
id: row.id as string,
|
|
40
|
+
path: row.path as string,
|
|
41
|
+
name: row.name as string,
|
|
42
|
+
lastUpdated: row.last_updated as string,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Map a snake_case DB row to camelCase Preference. */
|
|
47
|
+
function rowToPreference(row: Record<string, unknown>): Preference {
|
|
48
|
+
return {
|
|
49
|
+
id: row.id as string,
|
|
50
|
+
key: row.key as string,
|
|
51
|
+
value: row.value as string,
|
|
52
|
+
confidence: row.confidence as number,
|
|
53
|
+
sourceSession: (row.source_session as string) ?? null,
|
|
54
|
+
createdAt: row.created_at as string,
|
|
55
|
+
lastUpdated: row.last_updated as string,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Insert an observation. Validates via Zod before writing.
|
|
61
|
+
* Returns the observation with the generated id.
|
|
62
|
+
*/
|
|
63
|
+
export function insertObservation(obs: Omit<Observation, "id">, db?: Database): Observation {
|
|
64
|
+
const validated = observationSchema.omit({ id: true }).parse(obs);
|
|
65
|
+
const d = resolveDb(db);
|
|
66
|
+
|
|
67
|
+
d.run(
|
|
68
|
+
`INSERT INTO observations (project_id, session_id, type, content, summary, confidence, access_count, created_at, last_accessed)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
70
|
+
[
|
|
71
|
+
validated.projectId,
|
|
72
|
+
validated.sessionId,
|
|
73
|
+
validated.type,
|
|
74
|
+
validated.content,
|
|
75
|
+
validated.summary,
|
|
76
|
+
validated.confidence,
|
|
77
|
+
validated.accessCount,
|
|
78
|
+
validated.createdAt,
|
|
79
|
+
validated.lastAccessed,
|
|
80
|
+
],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const row = d.query("SELECT last_insert_rowid() as id").get() as { id: number };
|
|
84
|
+
return { ...validated, id: row.id };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Search observations using FTS5 MATCH with BM25 ranking.
|
|
89
|
+
* Filters by projectId (null for user-level observations).
|
|
90
|
+
*/
|
|
91
|
+
export function searchObservations(
|
|
92
|
+
query: string,
|
|
93
|
+
projectId: string | null,
|
|
94
|
+
limit = 20,
|
|
95
|
+
db?: Database,
|
|
96
|
+
): Array<Observation & { ftsRank: number }> {
|
|
97
|
+
const d = resolveDb(db);
|
|
98
|
+
|
|
99
|
+
const projectFilter = projectId === null ? "AND o.project_id IS NULL" : "AND o.project_id = ?";
|
|
100
|
+
|
|
101
|
+
// Sanitize FTS5 query — wrap in double-quotes to prevent operator injection
|
|
102
|
+
const safeFtsQuery = `"${query.replace(/"/g, '""')}"`;
|
|
103
|
+
const params: Array<string | number> =
|
|
104
|
+
projectId === null ? [safeFtsQuery, limit] : [safeFtsQuery, projectId, limit];
|
|
105
|
+
|
|
106
|
+
const rows = d
|
|
107
|
+
.query(
|
|
108
|
+
`SELECT o.*, bm25(observations_fts) as fts_rank
|
|
109
|
+
FROM observations_fts f
|
|
110
|
+
JOIN observations o ON o.id = f.rowid
|
|
111
|
+
WHERE observations_fts MATCH ?
|
|
112
|
+
${projectFilter}
|
|
113
|
+
ORDER BY fts_rank
|
|
114
|
+
LIMIT ?`,
|
|
115
|
+
)
|
|
116
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
117
|
+
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
...rowToObservation(row),
|
|
120
|
+
ftsRank: row.fts_rank as number,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create or replace a project record.
|
|
126
|
+
*/
|
|
127
|
+
export function upsertProject(project: Project, db?: Database): void {
|
|
128
|
+
const validated = projectSchema.parse(project);
|
|
129
|
+
const d = resolveDb(db);
|
|
130
|
+
d.run(`INSERT OR REPLACE INTO projects (id, path, name, last_updated) VALUES (?, ?, ?, ?)`, [
|
|
131
|
+
validated.id,
|
|
132
|
+
validated.path,
|
|
133
|
+
validated.name,
|
|
134
|
+
validated.lastUpdated,
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get a project by its filesystem path. Returns null if not found.
|
|
140
|
+
*/
|
|
141
|
+
export function getProjectByPath(path: string, db?: Database): Project | null {
|
|
142
|
+
const d = resolveDb(db);
|
|
143
|
+
const row = d.query("SELECT * FROM projects WHERE path = ?").get(path) as Record<
|
|
144
|
+
string,
|
|
145
|
+
unknown
|
|
146
|
+
> | null;
|
|
147
|
+
return row ? rowToProject(row) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get observations filtered by project_id, ordered by created_at DESC.
|
|
152
|
+
*/
|
|
153
|
+
export function getObservationsByProject(
|
|
154
|
+
projectId: string | null,
|
|
155
|
+
limit = 50,
|
|
156
|
+
db?: Database,
|
|
157
|
+
): readonly Observation[] {
|
|
158
|
+
const d = resolveDb(db);
|
|
159
|
+
|
|
160
|
+
const whereClause = projectId === null ? "WHERE project_id IS NULL" : "WHERE project_id = ?";
|
|
161
|
+
const params: Array<string | number> = projectId === null ? [limit] : [projectId, limit];
|
|
162
|
+
|
|
163
|
+
const rows = d
|
|
164
|
+
.query(`SELECT * FROM observations ${whereClause} ORDER BY created_at DESC LIMIT ?`)
|
|
165
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
166
|
+
|
|
167
|
+
return rows.map(rowToObservation);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create or replace a preference by its id.
|
|
172
|
+
*/
|
|
173
|
+
export function upsertPreference(pref: Preference, db?: Database): void {
|
|
174
|
+
const validated = preferenceSchema.parse(pref);
|
|
175
|
+
const d = resolveDb(db);
|
|
176
|
+
d.run(
|
|
177
|
+
`INSERT OR REPLACE INTO preferences (id, key, value, confidence, source_session, created_at, last_updated)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
179
|
+
[
|
|
180
|
+
validated.id,
|
|
181
|
+
validated.key,
|
|
182
|
+
validated.value,
|
|
183
|
+
validated.confidence,
|
|
184
|
+
validated.sourceSession,
|
|
185
|
+
validated.createdAt,
|
|
186
|
+
validated.lastUpdated,
|
|
187
|
+
],
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get all preferences.
|
|
193
|
+
*/
|
|
194
|
+
export function getAllPreferences(db?: Database): readonly Preference[] {
|
|
195
|
+
const d = resolveDb(db);
|
|
196
|
+
const rows = d.query("SELECT * FROM preferences").all() as Array<Record<string, unknown>>;
|
|
197
|
+
return rows.map(rowToPreference);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Delete an observation by id.
|
|
202
|
+
*/
|
|
203
|
+
export function deleteObservation(id: number, db?: Database): void {
|
|
204
|
+
const d = resolveDb(db);
|
|
205
|
+
d.run("DELETE FROM observations WHERE id = ?", [id]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Increment access_count and update last_accessed for an observation.
|
|
210
|
+
*/
|
|
211
|
+
export function updateAccessCount(id: number, db?: Database): void {
|
|
212
|
+
const d = resolveDb(db);
|
|
213
|
+
d.run("UPDATE observations SET access_count = access_count + 1, last_accessed = ? WHERE id = ?", [
|
|
214
|
+
new Date().toISOString(),
|
|
215
|
+
id,
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3-layer progressive disclosure retrieval with token-budgeted context building.
|
|
3
|
+
*
|
|
4
|
+
* Layer 1 (always): Observation summaries grouped by type (up to 5 per group)
|
|
5
|
+
* Layer 2 (if budget allows): Recent Activity timeline
|
|
6
|
+
* Layer 3 (if budget allows): Full content for top 1-2 observations
|
|
7
|
+
*
|
|
8
|
+
* Token budget enforcement: never exceeds CHARS_PER_TOKEN * tokenBudget characters.
|
|
9
|
+
* Same approach as buildMultiSkillContext in src/skills/adaptive-injector.ts.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
15
|
+
import { CHARS_PER_TOKEN, DEFAULT_INJECTION_BUDGET } from "./constants";
|
|
16
|
+
import { computeRelevanceScore } from "./decay";
|
|
17
|
+
import { getAllPreferences, getObservationsByProject, getProjectByPath } from "./repository";
|
|
18
|
+
import type { Observation, Preference } from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An observation with its computed relevance score.
|
|
22
|
+
*/
|
|
23
|
+
export type ScoredObservation = Observation & { readonly relevanceScore: number };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Score and rank observations by relevance (descending).
|
|
27
|
+
*/
|
|
28
|
+
export function scoreAndRankObservations(
|
|
29
|
+
observations: readonly Observation[],
|
|
30
|
+
halfLifeDays?: number,
|
|
31
|
+
): readonly ScoredObservation[] {
|
|
32
|
+
return observations
|
|
33
|
+
.map((obs) => ({
|
|
34
|
+
...obs,
|
|
35
|
+
relevanceScore: computeRelevanceScore(
|
|
36
|
+
obs.lastAccessed,
|
|
37
|
+
obs.accessCount,
|
|
38
|
+
obs.type,
|
|
39
|
+
halfLifeDays,
|
|
40
|
+
),
|
|
41
|
+
}))
|
|
42
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Type-to-section header mapping for Layer 1 grouping.
|
|
47
|
+
*/
|
|
48
|
+
const SECTION_HEADERS: Readonly<Record<string, string>> = Object.freeze({
|
|
49
|
+
decision: "### Key Decisions",
|
|
50
|
+
pattern: "### Patterns",
|
|
51
|
+
error: "### Recent Errors",
|
|
52
|
+
preference: "### Learned Preferences",
|
|
53
|
+
context: "### Context Notes",
|
|
54
|
+
tool_usage: "### Tool Usage Patterns",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Section display order (most valuable first). */
|
|
58
|
+
const SECTION_ORDER = ["decision", "pattern", "error", "preference", "context", "tool_usage"];
|
|
59
|
+
|
|
60
|
+
/** Max observations per group in Layer 1. */
|
|
61
|
+
const MAX_PER_GROUP = 5;
|
|
62
|
+
|
|
63
|
+
/** Minimum chars remaining to include Layer 2. */
|
|
64
|
+
const LAYER_2_THRESHOLD = 500;
|
|
65
|
+
|
|
66
|
+
/** Minimum chars remaining to include Layer 3. */
|
|
67
|
+
const LAYER_3_THRESHOLD = 1000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options for building memory context.
|
|
71
|
+
*/
|
|
72
|
+
interface BuildMemoryContextOptions {
|
|
73
|
+
readonly projectName: string;
|
|
74
|
+
readonly lastSessionDate: string | null;
|
|
75
|
+
readonly observations: readonly ScoredObservation[];
|
|
76
|
+
readonly preferences: readonly Preference[];
|
|
77
|
+
readonly tokenBudget?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a markdown memory context string within token budget.
|
|
82
|
+
*
|
|
83
|
+
* Uses 3-layer progressive disclosure:
|
|
84
|
+
* - Layer 1: Summaries grouped by type (always included if budget allows)
|
|
85
|
+
* - Layer 2: Recent Activity timeline (if remaining budget > 500 chars)
|
|
86
|
+
* - Layer 3: Full content for top observations (if remaining budget > 1000 chars)
|
|
87
|
+
*/
|
|
88
|
+
export function buildMemoryContext(options: BuildMemoryContextOptions): string {
|
|
89
|
+
const {
|
|
90
|
+
projectName,
|
|
91
|
+
lastSessionDate,
|
|
92
|
+
observations,
|
|
93
|
+
preferences,
|
|
94
|
+
tokenBudget = DEFAULT_INJECTION_BUDGET,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
if (observations.length === 0 && preferences.length === 0) return "";
|
|
98
|
+
|
|
99
|
+
const charBudget = tokenBudget * CHARS_PER_TOKEN;
|
|
100
|
+
let totalChars = 0;
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
|
|
103
|
+
// Header
|
|
104
|
+
const header = `## Project Memory (auto-injected)\n**Project:** ${projectName}\n**Last session:** ${lastSessionDate ?? "first session"}\n`;
|
|
105
|
+
if (totalChars + header.length > charBudget) {
|
|
106
|
+
return header.slice(0, charBudget);
|
|
107
|
+
}
|
|
108
|
+
parts.push(header);
|
|
109
|
+
totalChars += header.length;
|
|
110
|
+
|
|
111
|
+
// --- Layer 1: Grouped summaries ---
|
|
112
|
+
// Sort by relevance within the function to ensure highest-first in each group
|
|
113
|
+
const sorted = [...observations].sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
114
|
+
const grouped = groupByType(sorted);
|
|
115
|
+
|
|
116
|
+
for (const type of SECTION_ORDER) {
|
|
117
|
+
const group = grouped.get(type);
|
|
118
|
+
if (!group || group.length === 0) continue;
|
|
119
|
+
|
|
120
|
+
const sectionHeader = SECTION_HEADERS[type] ?? `### ${type}`;
|
|
121
|
+
const items = group.slice(0, MAX_PER_GROUP);
|
|
122
|
+
const lines = items.map((obs) => `- ${obs.summary} (confidence: ${obs.confidence})`);
|
|
123
|
+
const section = `\n${sectionHeader}\n${lines.join("\n")}\n`;
|
|
124
|
+
|
|
125
|
+
if (totalChars + section.length > charBudget) break;
|
|
126
|
+
parts.push(section);
|
|
127
|
+
totalChars += section.length;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Preferences section
|
|
131
|
+
if (preferences.length > 0) {
|
|
132
|
+
const prefLines = preferences.map((p) => `- **${p.key}:** ${p.value}`);
|
|
133
|
+
const prefSection = `\n### Preferences\n${prefLines.join("\n")}\n`;
|
|
134
|
+
|
|
135
|
+
if (totalChars + prefSection.length <= charBudget) {
|
|
136
|
+
parts.push(prefSection);
|
|
137
|
+
totalChars += prefSection.length;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Layer 2: Recent Activity timeline (if budget allows) ---
|
|
142
|
+
const remainingAfterL1 = charBudget - totalChars;
|
|
143
|
+
if (remainingAfterL1 > LAYER_2_THRESHOLD && observations.length > 0) {
|
|
144
|
+
const timeline = buildTimeline(observations);
|
|
145
|
+
if (timeline.length > 0) {
|
|
146
|
+
const timelineSection = `\n### Recent Activity\n${timeline}\n`;
|
|
147
|
+
if (totalChars + timelineSection.length <= charBudget) {
|
|
148
|
+
parts.push(timelineSection);
|
|
149
|
+
totalChars += timelineSection.length;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Layer 3: Full content for top observations (if budget allows) ---
|
|
155
|
+
const remainingAfterL2 = charBudget - totalChars;
|
|
156
|
+
if (remainingAfterL2 > LAYER_3_THRESHOLD && observations.length > 0) {
|
|
157
|
+
const topObs = observations.slice(0, 2);
|
|
158
|
+
const detailLines: string[] = [];
|
|
159
|
+
const headerOverhead = "\n### Details\n\n".length;
|
|
160
|
+
let linesBudget = remainingAfterL2 - headerOverhead;
|
|
161
|
+
|
|
162
|
+
for (const obs of topObs) {
|
|
163
|
+
const detail = `**${obs.type}:** ${obs.content}`;
|
|
164
|
+
const cost = detail.length + 1;
|
|
165
|
+
if (cost > linesBudget) break;
|
|
166
|
+
detailLines.push(detail);
|
|
167
|
+
linesBudget -= cost;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (detailLines.length > 0) {
|
|
171
|
+
const detailSection = `\n### Details\n${detailLines.join("\n")}\n`;
|
|
172
|
+
if (totalChars + detailSection.length <= charBudget) {
|
|
173
|
+
parts.push(detailSection);
|
|
174
|
+
totalChars += detailSection.length;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = parts.join("");
|
|
180
|
+
// Final safety truncation
|
|
181
|
+
return result.length > charBudget ? result.slice(0, charBudget) : result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Group scored observations by type, preserving relevance order within groups.
|
|
186
|
+
*/
|
|
187
|
+
function groupByType(
|
|
188
|
+
observations: readonly ScoredObservation[],
|
|
189
|
+
): ReadonlyMap<string, readonly ScoredObservation[]> {
|
|
190
|
+
const groups = new Map<string, ScoredObservation[]>();
|
|
191
|
+
|
|
192
|
+
for (const obs of observations) {
|
|
193
|
+
const existing = groups.get(obs.type);
|
|
194
|
+
if (existing) {
|
|
195
|
+
existing.push(obs);
|
|
196
|
+
} else {
|
|
197
|
+
groups.set(obs.type, [obs]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return groups;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build a brief timeline of recent sessions from observations.
|
|
206
|
+
*/
|
|
207
|
+
function buildTimeline(observations: readonly ScoredObservation[]): string {
|
|
208
|
+
// Group by session, take last 5 sessions
|
|
209
|
+
const sessions = new Map<string, { date: string; count: number }>();
|
|
210
|
+
|
|
211
|
+
for (const obs of observations) {
|
|
212
|
+
const existing = sessions.get(obs.sessionId);
|
|
213
|
+
if (existing) {
|
|
214
|
+
existing.count++;
|
|
215
|
+
if (new Date(obs.createdAt).getTime() > new Date(existing.date).getTime()) {
|
|
216
|
+
existing.date = obs.createdAt;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
sessions.set(obs.sessionId, { date: obs.createdAt, count: 1 });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const sorted = [...sessions.values()]
|
|
224
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
225
|
+
.slice(0, 5);
|
|
226
|
+
|
|
227
|
+
return sorted
|
|
228
|
+
.map((s) => {
|
|
229
|
+
const dateStr = s.date.split("T")[0];
|
|
230
|
+
return `- ${dateStr}: ${s.count} observation${s.count !== 1 ? "s" : ""}`;
|
|
231
|
+
})
|
|
232
|
+
.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Convenience function: retrieve memory context for a project path.
|
|
237
|
+
*
|
|
238
|
+
* Ties together: project lookup, observation retrieval, scoring, preferences, and context building.
|
|
239
|
+
*/
|
|
240
|
+
export function retrieveMemoryContext(
|
|
241
|
+
projectPath: string,
|
|
242
|
+
tokenBudget?: number,
|
|
243
|
+
db?: Database,
|
|
244
|
+
halfLifeDays?: number,
|
|
245
|
+
): string {
|
|
246
|
+
const project = getProjectByPath(projectPath, db);
|
|
247
|
+
if (!project) return "";
|
|
248
|
+
|
|
249
|
+
const observations = getObservationsByProject(project.id, 100, db);
|
|
250
|
+
const scored = scoreAndRankObservations(observations, halfLifeDays);
|
|
251
|
+
const preferences = getAllPreferences(db);
|
|
252
|
+
|
|
253
|
+
return buildMemoryContext({
|
|
254
|
+
projectName: project.name,
|
|
255
|
+
lastSessionDate: project.lastUpdated,
|
|
256
|
+
observations: scored,
|
|
257
|
+
preferences,
|
|
258
|
+
tokenBudget,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { OBSERVATION_TYPES } from "./constants";
|
|
3
|
+
|
|
4
|
+
export const observationTypeSchema = z.enum(OBSERVATION_TYPES);
|
|
5
|
+
|
|
6
|
+
export const observationSchema = z.object({
|
|
7
|
+
id: z.number().int().optional(),
|
|
8
|
+
projectId: z.string().nullable(),
|
|
9
|
+
sessionId: z.string(),
|
|
10
|
+
type: observationTypeSchema,
|
|
11
|
+
content: z.string().min(1).max(10000),
|
|
12
|
+
summary: z.string().min(1).max(500),
|
|
13
|
+
confidence: z.number().min(0).max(1).default(0.5),
|
|
14
|
+
accessCount: z.number().int().min(0).default(0),
|
|
15
|
+
createdAt: z.string(),
|
|
16
|
+
lastAccessed: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const projectSchema = z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
path: z.string(),
|
|
22
|
+
name: z.string(),
|
|
23
|
+
lastUpdated: z.string(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const preferenceSchema = z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
key: z.string().min(1).max(200),
|
|
29
|
+
value: z.string().min(1).max(2000),
|
|
30
|
+
confidence: z.number().min(0).max(1).default(0.5),
|
|
31
|
+
sourceSession: z.string().nullable().default(null),
|
|
32
|
+
createdAt: z.string(),
|
|
33
|
+
lastUpdated: z.string(),
|
|
34
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
observationSchema,
|
|
4
|
+
observationTypeSchema,
|
|
5
|
+
preferenceSchema,
|
|
6
|
+
projectSchema,
|
|
7
|
+
} from "./schemas";
|
|
8
|
+
|
|
9
|
+
export type ObservationType = z.infer<typeof observationTypeSchema>;
|
|
10
|
+
export type Observation = z.infer<typeof observationSchema>;
|
|
11
|
+
export type Project = z.infer<typeof projectSchema>;
|
|
12
|
+
export type Preference = z.infer<typeof preferenceSchema>;
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { sanitizeTemplateContent } from "../review/sanitize";
|
|
13
|
+
import {
|
|
14
|
+
buildMultiSkillContext,
|
|
15
|
+
detectProjectStackTags,
|
|
16
|
+
filterSkillsByStack,
|
|
17
|
+
} from "../skills/adaptive-injector";
|
|
18
|
+
import { loadAllSkills } from "../skills/loader";
|
|
13
19
|
import { isEnoentError } from "../utils/fs-helpers";
|
|
14
20
|
|
|
15
21
|
const MAX_SKILL_LENGTH = 2048;
|
|
@@ -50,3 +56,35 @@ export function buildSkillContext(skillContent: string): string {
|
|
|
50
56
|
const header = "Coding standards for this project (follow these conventions):";
|
|
51
57
|
return `\n\n${header}\n${sanitized}`;
|
|
52
58
|
}
|
|
59
|
+
|
|
60
|
+
// --- Adaptive multi-skill loading ---
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load and inject all matching skills for the current project.
|
|
64
|
+
* Detects project stack from manifest files, filters skills by stack,
|
|
65
|
+
* resolves dependencies, and builds a token-budgeted context string.
|
|
66
|
+
*
|
|
67
|
+
* Returns empty string on any error (best-effort, same as lesson injection).
|
|
68
|
+
*/
|
|
69
|
+
export async function loadAdaptiveSkillContext(
|
|
70
|
+
baseDir: string,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
tokenBudget?: number,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
try {
|
|
75
|
+
const skillsDir = join(baseDir, "skills");
|
|
76
|
+
const [allSkills, projectTags] = await Promise.all([
|
|
77
|
+
loadAllSkills(skillsDir),
|
|
78
|
+
detectProjectStackTags(projectRoot),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
if (allSkills.size === 0) return "";
|
|
82
|
+
|
|
83
|
+
const matchingSkills = filterSkillsByStack(allSkills, projectTags);
|
|
84
|
+
return buildMultiSkillContext(matchingSkills, tokenBudget);
|
|
85
|
+
} catch (error: unknown) {
|
|
86
|
+
// Best-effort for I/O errors; re-throw programmer errors
|
|
87
|
+
if (error instanceof TypeError || error instanceof RangeError) throw error;
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/review/sanitize.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* {{PRIOR_FINDINGS}} or similar tokens that get substituted in a subsequent .replace() call.
|
|
5
5
|
*/
|
|
6
6
|
export function sanitizeTemplateContent(content: string): string {
|
|
7
|
-
return content.replace(/\{\{[
|
|
7
|
+
return content.replace(/\{\{[\w]+\}\}/gi, "[REDACTED]");
|
|
8
8
|
}
|