akm-opencode 0.8.0-rc1 → 0.8.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/README.md +90 -30
- package/agent/akm-curator.md +4 -4
- package/commands/akm-improve-asset.md +9 -0
- package/commands/akm-propose-asset.md +1 -1
- package/commands/akm-review-proposals.md +2 -1
- package/index.ts +1067 -532
- package/package.json +10 -3
- package/shared/feedback-signals.ts +74 -0
- package/shared/memory-candidates.ts +196 -0
- package/shared/memory-events.ts +122 -0
- package/shared/recall-policy.ts +70 -0
- package/shared/redaction.ts +292 -0
- package/shared/ref-extraction.ts +261 -0
- package/commands/akm-distill-lesson.md +0 -7
- package/commands/akm-reflect-on-failure.md +0 -7
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-opencode",
|
|
3
|
-
"version": "0.8.0
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenCode plugin for AKM v0.
|
|
5
|
+
"description": "OpenCode plugin for AKM v0.8.0 - search, show, and manage extension assets via the akm CLI, including vaults, wikis, workflows, the proposal queue, lesson assets, and improve/propose commands, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"opencode",
|
|
8
8
|
"opencode-ai",
|
|
@@ -27,13 +27,20 @@
|
|
|
27
27
|
"index.ts",
|
|
28
28
|
"agent/",
|
|
29
29
|
"commands/",
|
|
30
|
+
"shared/",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|
|
32
33
|
"main": "./index.ts",
|
|
33
34
|
"publishConfig": {
|
|
34
35
|
"access": "public"
|
|
35
36
|
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"prepack": "node ./scripts/vendor-shared.mjs",
|
|
39
|
+
"postpack": "node ./scripts/unvendor-shared.mjs"
|
|
40
|
+
},
|
|
36
41
|
"dependencies": {
|
|
37
|
-
"@opencode-ai/plugin": "^1.2.20"
|
|
42
|
+
"@opencode-ai/plugin": "^1.2.20",
|
|
43
|
+
"akm-cli": "^0.8.0",
|
|
44
|
+
"semver": "^7.7.2"
|
|
38
45
|
}
|
|
39
46
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type AkmFeedbackSignal = {
|
|
2
|
+
ref: string
|
|
3
|
+
polarity: "positive" | "negative"
|
|
4
|
+
confidence: number
|
|
5
|
+
source:
|
|
6
|
+
| "explicit_user_feedback"
|
|
7
|
+
| "tool_success"
|
|
8
|
+
| "tool_failure"
|
|
9
|
+
| "retrospective_positive"
|
|
10
|
+
| "retrospective_negative"
|
|
11
|
+
| "workflow_completion"
|
|
12
|
+
| "proposal_acceptance"
|
|
13
|
+
| "proposal_rejection"
|
|
14
|
+
| "curator_assessment"
|
|
15
|
+
note: string
|
|
16
|
+
sessionId?: string
|
|
17
|
+
harness: "claude-code" | "opencode"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getAutoFeedbackMinConfidence(): number {
|
|
21
|
+
const raw = Number(process.env.AKM_AUTO_FEEDBACK_MIN_CONFIDENCE ?? "0.6")
|
|
22
|
+
return Number.isFinite(raw) ? raw : 0.6
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function classifyFeedbackSignal(input: {
|
|
26
|
+
ref: string
|
|
27
|
+
polarity: "positive" | "negative"
|
|
28
|
+
harness: "claude-code" | "opencode"
|
|
29
|
+
sessionId?: string
|
|
30
|
+
directInput?: boolean
|
|
31
|
+
explicitUser?: boolean
|
|
32
|
+
retrospective?: boolean
|
|
33
|
+
workflowCompletion?: boolean
|
|
34
|
+
proposalAction?: "accept" | "reject"
|
|
35
|
+
curatorAssessment?: boolean
|
|
36
|
+
note?: string
|
|
37
|
+
}): AkmFeedbackSignal {
|
|
38
|
+
let source: AkmFeedbackSignal["source"] = input.polarity === "positive" ? "tool_success" : "tool_failure"
|
|
39
|
+
let confidence = input.directInput ? 0.65 : 0.25
|
|
40
|
+
|
|
41
|
+
if (input.explicitUser) {
|
|
42
|
+
source = "explicit_user_feedback"
|
|
43
|
+
confidence = 0.95
|
|
44
|
+
} else if (input.retrospective) {
|
|
45
|
+
source = input.polarity === "positive" ? "retrospective_positive" : "retrospective_negative"
|
|
46
|
+
confidence = input.polarity === "positive" ? 0.7 : 0.8
|
|
47
|
+
} else if (input.workflowCompletion) {
|
|
48
|
+
source = "workflow_completion"
|
|
49
|
+
confidence = 0.85
|
|
50
|
+
} else if (input.proposalAction === "accept") {
|
|
51
|
+
source = "proposal_acceptance"
|
|
52
|
+
confidence = 0.95
|
|
53
|
+
} else if (input.proposalAction === "reject") {
|
|
54
|
+
source = "proposal_rejection"
|
|
55
|
+
confidence = 0.85
|
|
56
|
+
} else if (input.curatorAssessment) {
|
|
57
|
+
source = "curator_assessment"
|
|
58
|
+
confidence = 0.75
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ref: input.ref,
|
|
63
|
+
polarity: input.polarity,
|
|
64
|
+
confidence,
|
|
65
|
+
source,
|
|
66
|
+
note: input.note ?? `${input.harness} auto: source=${source}; confidence=${confidence.toFixed(2)}`,
|
|
67
|
+
sessionId: input.sessionId,
|
|
68
|
+
harness: input.harness,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function shouldSubmitAutomaticFeedback(signal: AkmFeedbackSignal): boolean {
|
|
73
|
+
return signal.confidence >= getAutoFeedbackMinConfidence()
|
|
74
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { redactObject } from "./redaction"
|
|
4
|
+
|
|
5
|
+
// Memory candidates can contain prompt fragments, ref names, and (despite
|
|
6
|
+
// redaction) potentially sensitive contextual data harvested from session
|
|
7
|
+
// activity. Lock the on-disk file down to user-only read/write so multi-user
|
|
8
|
+
// hosts (CI runners, shared VMs, dev sandboxes) cannot side-read another
|
|
9
|
+
// user's stash signals.
|
|
10
|
+
function chmodSafe(filePath: string, mode: number): void {
|
|
11
|
+
try {
|
|
12
|
+
chmodSync(filePath, mode)
|
|
13
|
+
} catch {
|
|
14
|
+
// Best-effort: filesystems without POSIX mode (FAT, some FUSE mounts) or
|
|
15
|
+
// platforms where chmod is a no-op (Windows) silently skip. Don't crash
|
|
16
|
+
// the hook over a hardening attempt.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type AkmMemoryCandidate = {
|
|
21
|
+
id: string
|
|
22
|
+
createdAt: string
|
|
23
|
+
harness: "claude-code" | "opencode"
|
|
24
|
+
sessionId?: string
|
|
25
|
+
sourceEventIds?: string[]
|
|
26
|
+
sourcePaths?: string[]
|
|
27
|
+
type:
|
|
28
|
+
| "preference"
|
|
29
|
+
| "constraint"
|
|
30
|
+
| "decision"
|
|
31
|
+
| "lesson"
|
|
32
|
+
| "workflow_state"
|
|
33
|
+
| "asset_feedback"
|
|
34
|
+
| "coverage_gap"
|
|
35
|
+
| "stale_memory"
|
|
36
|
+
| "unknown"
|
|
37
|
+
scope: "user" | "project" | "repo" | "branch" | "session" | "agent" | "workflow"
|
|
38
|
+
content: string
|
|
39
|
+
evidence: string[]
|
|
40
|
+
confidence: number
|
|
41
|
+
recommendedAction: "remember" | "distill" | "propose" | "feedback" | "ignore"
|
|
42
|
+
targetRef?: string
|
|
43
|
+
status: "pending" | "promoted" | "rejected"
|
|
44
|
+
reason?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const AKM_REF_RE = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|env|secret|wiki|lesson):[A-Za-z0-9._/-]+/g
|
|
48
|
+
|
|
49
|
+
function uniq(values: string[]): string[] {
|
|
50
|
+
return [...new Set(values)]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pickTargetRef(refs: string[]): string | undefined {
|
|
54
|
+
return refs.find((ref) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:")) ?? refs[0]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractRefs(value: string): string[] {
|
|
58
|
+
return uniq(value.match(AKM_REF_RE) ?? [])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getCandidateLogPath(harness: "claude-code" | "opencode"): string {
|
|
62
|
+
const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
|
|
63
|
+
const dir = path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
|
|
64
|
+
return path.join(dir, "memory-candidates.jsonl")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeCandidateId(harness: string, sessionId: string | undefined, index: number): string {
|
|
68
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)
|
|
69
|
+
const sid = (sessionId ?? "session").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 12) || "session"
|
|
70
|
+
return `${harness}-${sid}-${stamp}-${index}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function classifyCandidate(
|
|
74
|
+
content: string,
|
|
75
|
+
refs: string[],
|
|
76
|
+
): Pick<AkmMemoryCandidate, "type" | "scope" | "confidence" | "recommendedAction" | "targetRef"> {
|
|
77
|
+
const text = content.toLowerCase()
|
|
78
|
+
const targetRef = pickTargetRef(refs)
|
|
79
|
+
if (targetRef && /\b(worked|helped|useful|failed|broken|wrong|didn't work|did not work)\b/.test(text)) {
|
|
80
|
+
return { type: "asset_feedback", scope: "project", confidence: 0.72, recommendedAction: "feedback", targetRef }
|
|
81
|
+
}
|
|
82
|
+
if (/\b(always|never|must|should not|cannot|can't|do not|required)\b/.test(text)) {
|
|
83
|
+
return { type: "constraint", scope: "project", confidence: 0.8, recommendedAction: "remember" }
|
|
84
|
+
}
|
|
85
|
+
if (/\b(prefer|likes|dislikes|wants|remember)\b/.test(text)) {
|
|
86
|
+
return { type: "preference", scope: "user", confidence: 0.8, recommendedAction: "remember" }
|
|
87
|
+
}
|
|
88
|
+
if (/\b(decided|decision|use |chosen|architecture|approach)\b/.test(text)) {
|
|
89
|
+
return { type: "decision", scope: "project", confidence: 0.75, recommendedAction: "remember" }
|
|
90
|
+
}
|
|
91
|
+
if (/\b(blocked|failed|workflow|next step|resume)\b/.test(text)) {
|
|
92
|
+
return { type: "workflow_state", scope: "workflow", confidence: 0.7, recommendedAction: "remember" }
|
|
93
|
+
}
|
|
94
|
+
if (/\b(missing|coverage gap|todo|follow-up|followup)\b/.test(text)) {
|
|
95
|
+
return { type: "coverage_gap", scope: "project", confidence: 0.65, recommendedAction: "propose" }
|
|
96
|
+
}
|
|
97
|
+
if (/\b(lesson|fix|worked|resolved|solution)\b/.test(text)) {
|
|
98
|
+
return { type: "lesson", scope: "project", confidence: 0.7, recommendedAction: "distill", targetRef }
|
|
99
|
+
}
|
|
100
|
+
return { type: "unknown", scope: "session", confidence: 0.4, recommendedAction: "ignore" }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractCandidatesFromText(input: {
|
|
104
|
+
harness: "claude-code" | "opencode"
|
|
105
|
+
sessionId?: string
|
|
106
|
+
text: string
|
|
107
|
+
evidence?: string[]
|
|
108
|
+
sourceEventIds?: string[]
|
|
109
|
+
sourcePaths?: string[]
|
|
110
|
+
targetRefHints?: string[]
|
|
111
|
+
}): AkmMemoryCandidate[] {
|
|
112
|
+
const lines = input.text
|
|
113
|
+
.split(/\r?\n/)
|
|
114
|
+
.map((line) => line.trim())
|
|
115
|
+
.filter((line) => line.length >= 20)
|
|
116
|
+
.filter((line) => !line.startsWith("#") && !line.startsWith("## "))
|
|
117
|
+
|
|
118
|
+
const selected = lines.filter((line) => /\b(always|never|must|should|prefer|remember|decision|decided|workflow|blocked|failed|worked|fix|missing|follow-up|followup)\b/i.test(line))
|
|
119
|
+
return selected.slice(0, 12).map((content, index) => {
|
|
120
|
+
const contentRefs = extractRefs(content)
|
|
121
|
+
const evidenceRefs = (input.evidence ?? []).flatMap(extractRefs)
|
|
122
|
+
const hintedRefs = input.targetRefHints ?? []
|
|
123
|
+
const refs = uniq([
|
|
124
|
+
...contentRefs,
|
|
125
|
+
...hintedRefs,
|
|
126
|
+
...evidenceRefs,
|
|
127
|
+
])
|
|
128
|
+
const details = classifyCandidate(content, refs)
|
|
129
|
+
const targetRef = details.targetRef ?? pickTargetRef([...contentRefs, ...hintedRefs, ...evidenceRefs])
|
|
130
|
+
return {
|
|
131
|
+
id: makeCandidateId(input.harness, input.sessionId, index + 1),
|
|
132
|
+
createdAt: new Date().toISOString(),
|
|
133
|
+
harness: input.harness,
|
|
134
|
+
sessionId: input.sessionId,
|
|
135
|
+
sourceEventIds: input.sourceEventIds,
|
|
136
|
+
sourcePaths: input.sourcePaths ? uniq(input.sourcePaths) : undefined,
|
|
137
|
+
type: details.type,
|
|
138
|
+
scope: details.scope,
|
|
139
|
+
content,
|
|
140
|
+
evidence: uniq([...(input.evidence ?? []), content]),
|
|
141
|
+
confidence: details.confidence,
|
|
142
|
+
recommendedAction: details.recommendedAction,
|
|
143
|
+
targetRef,
|
|
144
|
+
status: "pending",
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function appendCandidates(filePath: string, candidates: AkmMemoryCandidate[]): { ok: true; count: number; categories: string[] } | { ok: false; error: string } {
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
152
|
+
chmodSafe(path.dirname(filePath), 0o700)
|
|
153
|
+
const categories: string[] = []
|
|
154
|
+
const created = !existsSync(filePath)
|
|
155
|
+
for (const candidate of candidates) {
|
|
156
|
+
const redacted = redactObject(candidate)
|
|
157
|
+
categories.push(...redacted.categories)
|
|
158
|
+
appendFileSync(filePath, `${JSON.stringify(redacted.value)}\n`)
|
|
159
|
+
}
|
|
160
|
+
if (created) chmodSafe(filePath, 0o600)
|
|
161
|
+
return { ok: true, count: candidates.length, categories: [...new Set(categories)] }
|
|
162
|
+
} catch (error: unknown) {
|
|
163
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function readCandidates(filePath: string): AkmMemoryCandidate[] {
|
|
168
|
+
if (!existsSync(filePath)) return []
|
|
169
|
+
return readFileSync(filePath, "utf8")
|
|
170
|
+
.split("\n")
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.flatMap((line) => {
|
|
173
|
+
try {
|
|
174
|
+
return [JSON.parse(line) as AkmMemoryCandidate]
|
|
175
|
+
} catch {
|
|
176
|
+
return []
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function replaceCandidates(filePath: string, candidates: AkmMemoryCandidate[]): void {
|
|
182
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
183
|
+
chmodSafe(path.dirname(filePath), 0o700)
|
|
184
|
+
writeFileSync(filePath, candidates.map((candidate) => `${JSON.stringify(candidate)}\n`).join(""))
|
|
185
|
+
chmodSafe(filePath, 0o600)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function updateCandidateStatus(filePath: string, id: string, status: "promoted" | "rejected", reason?: string): AkmMemoryCandidate | undefined {
|
|
189
|
+
const candidates = readCandidates(filePath)
|
|
190
|
+
const index = candidates.findIndex((candidate) => candidate.id === id)
|
|
191
|
+
if (index === -1) return undefined
|
|
192
|
+
const updated = { ...candidates[index], status, reason }
|
|
193
|
+
candidates[index] = updated
|
|
194
|
+
replaceCandidates(filePath, candidates)
|
|
195
|
+
return updated
|
|
196
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { redactObject } from "./redaction"
|
|
4
|
+
|
|
5
|
+
// Memory events log session activity (refs touched, outcomes, scope). Even
|
|
6
|
+
// post-redaction this is a privileged record; lock the directory + file down
|
|
7
|
+
// to owner-only access. Mirrors memory-candidates' chmodSafe helper.
|
|
8
|
+
function chmodSafe(target: string, mode: number): void {
|
|
9
|
+
try {
|
|
10
|
+
chmodSync(target, mode)
|
|
11
|
+
} catch {
|
|
12
|
+
// Best-effort; see memory-candidates.ts for rationale.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AkmMemoryEventType =
|
|
17
|
+
| "session_started"
|
|
18
|
+
| "prompt_recall"
|
|
19
|
+
| "tool_observation"
|
|
20
|
+
| "tool_batch_observation"
|
|
21
|
+
| "tool_ref_observed"
|
|
22
|
+
| "workflow_step"
|
|
23
|
+
| "workflow_started"
|
|
24
|
+
| "workflow_next_loaded"
|
|
25
|
+
| "workflow_step_completed"
|
|
26
|
+
| "workflow_step_blocked"
|
|
27
|
+
| "workflow_step_failed"
|
|
28
|
+
| "workflow_step_skipped"
|
|
29
|
+
| "workflow_evidence_attached"
|
|
30
|
+
| "workflow_drift_detected"
|
|
31
|
+
| "workflow_resumed"
|
|
32
|
+
| "workflow_abandoned"
|
|
33
|
+
| "task_created"
|
|
34
|
+
| "task_completed"
|
|
35
|
+
| "subagent_started"
|
|
36
|
+
| "subagent_completed"
|
|
37
|
+
| "pre_compact_checkpoint"
|
|
38
|
+
| "post_compact_summary"
|
|
39
|
+
| "session_ended"
|
|
40
|
+
| "candidate_extracted"
|
|
41
|
+
| "candidate_promoted"
|
|
42
|
+
| "candidate_rejected"
|
|
43
|
+
| "durable_memory_written"
|
|
44
|
+
| "feedback_recorded"
|
|
45
|
+
| "safety_blocked"
|
|
46
|
+
|
|
47
|
+
export type AkmMemoryEvent = {
|
|
48
|
+
version: 1
|
|
49
|
+
timestamp: string
|
|
50
|
+
harness: "claude-code" | "opencode"
|
|
51
|
+
event: AkmMemoryEventType
|
|
52
|
+
eventId?: string
|
|
53
|
+
sessionId?: string
|
|
54
|
+
project?: string
|
|
55
|
+
agent?: string
|
|
56
|
+
workflowRunId?: string
|
|
57
|
+
scope?: {
|
|
58
|
+
user?: string
|
|
59
|
+
agent?: string
|
|
60
|
+
run?: string
|
|
61
|
+
channel?: string
|
|
62
|
+
project?: string
|
|
63
|
+
repo?: string
|
|
64
|
+
branch?: string
|
|
65
|
+
}
|
|
66
|
+
input?: Record<string, unknown>
|
|
67
|
+
memory?: Record<string, unknown>
|
|
68
|
+
refs?: string[]
|
|
69
|
+
outcome?: {
|
|
70
|
+
status: "ok" | "skipped" | "blocked" | "failed"
|
|
71
|
+
warnings?: string[]
|
|
72
|
+
error?: string
|
|
73
|
+
}
|
|
74
|
+
redaction?: {
|
|
75
|
+
redacted: boolean
|
|
76
|
+
categories: string[]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getHarnessStateDir(harness: "claude-code" | "opencode"): string {
|
|
81
|
+
const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
|
|
82
|
+
return path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getEventLogPath(harness: "claude-code" | "opencode"): string {
|
|
86
|
+
return path.join(getHarnessStateDir(harness), "events.jsonl")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok: true; redacted: boolean; categories: string[] } | { ok: false; error: string } {
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
92
|
+
chmodSafe(path.dirname(filePath), 0o700)
|
|
93
|
+
const redacted = redactObject(event)
|
|
94
|
+
const enriched = {
|
|
95
|
+
...redacted.value,
|
|
96
|
+
redaction: {
|
|
97
|
+
redacted: redacted.redacted,
|
|
98
|
+
categories: redacted.categories,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
const created = !existsSync(filePath)
|
|
102
|
+
appendFileSync(filePath, `${JSON.stringify(enriched)}\n`)
|
|
103
|
+
if (created) chmodSafe(filePath, 0o600)
|
|
104
|
+
return { ok: true, redacted: redacted.redacted, categories: redacted.categories }
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readJsonl<T>(filePath: string): T[] {
|
|
111
|
+
if (!existsSync(filePath)) return []
|
|
112
|
+
return readFileSync(filePath, "utf8")
|
|
113
|
+
.split("\n")
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.flatMap((line) => {
|
|
116
|
+
try {
|
|
117
|
+
return [JSON.parse(line) as T]
|
|
118
|
+
} catch {
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type RecallDecision = {
|
|
2
|
+
shouldRecall: boolean
|
|
3
|
+
reason:
|
|
4
|
+
| "explicit-akm"
|
|
5
|
+
| "long-prompt"
|
|
6
|
+
| "memory-intent"
|
|
7
|
+
| "workflow-intent"
|
|
8
|
+
| "agent-dispatch"
|
|
9
|
+
| "command-dispatch"
|
|
10
|
+
| "wiki-intent"
|
|
11
|
+
| "proposal-intent"
|
|
12
|
+
| "release-intent"
|
|
13
|
+
| "coding-task"
|
|
14
|
+
| "active-workflow"
|
|
15
|
+
| "recent-asset-failure"
|
|
16
|
+
| "skip-short"
|
|
17
|
+
| "skip-chitchat"
|
|
18
|
+
| "skip-low-signal"
|
|
19
|
+
query: string
|
|
20
|
+
scopeHints?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const REF_RE = /(?:skill|command|agent|knowledge|workflow|lesson|wiki|memory|env|secret):[A-Za-z0-9._/-]+/i
|
|
24
|
+
|
|
25
|
+
export function shouldRecall(prompt: string, options?: { activeWorkflow?: boolean; recentAssetFailure?: boolean }): RecallDecision {
|
|
26
|
+
const text = prompt.trim()
|
|
27
|
+
const lower = text.toLowerCase()
|
|
28
|
+
const scopeHints: string[] = []
|
|
29
|
+
if (!text) return { shouldRecall: false, reason: "skip-low-signal", query: "", scopeHints }
|
|
30
|
+
if (options?.activeWorkflow) return { shouldRecall: true, reason: "active-workflow", query: text, scopeHints: ["workflow"] }
|
|
31
|
+
if (options?.recentAssetFailure) return { shouldRecall: true, reason: "recent-asset-failure", query: text, scopeHints }
|
|
32
|
+
if (text.length < 4 || /^(ok|thanks|thank you|yes|no|continue|go ahead|sure|cool)$/i.test(lower)) {
|
|
33
|
+
return { shouldRecall: false, reason: "skip-short", query: text, scopeHints }
|
|
34
|
+
}
|
|
35
|
+
if (/\b(hi|hello|how are you|good morning|good night)\b/i.test(lower) && text.length < 40) {
|
|
36
|
+
return { shouldRecall: false, reason: "skip-chitchat", query: text, scopeHints }
|
|
37
|
+
}
|
|
38
|
+
if (/\bakm\b|\bstash\b/.test(lower) || REF_RE.test(text)) {
|
|
39
|
+
return { shouldRecall: true, reason: "explicit-akm", query: text, scopeHints: ["akm"] }
|
|
40
|
+
}
|
|
41
|
+
if (/\b(remember|memory|prior session|previous decision)\b/.test(lower)) {
|
|
42
|
+
return { shouldRecall: true, reason: "memory-intent", query: text, scopeHints: ["memory"] }
|
|
43
|
+
}
|
|
44
|
+
if (/\b(workflow|resume|complete step|next step|blocked step)\b/.test(lower)) {
|
|
45
|
+
return { shouldRecall: true, reason: "workflow-intent", query: text, scopeHints: ["workflow"] }
|
|
46
|
+
}
|
|
47
|
+
if (/\b(dispatch|agent|subagent|reviewer|planner|curator)\b/.test(lower)) {
|
|
48
|
+
return { shouldRecall: true, reason: "agent-dispatch", query: text, scopeHints: ["agent"] }
|
|
49
|
+
}
|
|
50
|
+
if (/\b(command|slash command|run the stash command)\b/.test(lower)) {
|
|
51
|
+
return { shouldRecall: true, reason: "command-dispatch", query: text, scopeHints: ["command"] }
|
|
52
|
+
}
|
|
53
|
+
if (/\b(wiki|docs|knowledge base|ingest|lint)\b/.test(lower)) {
|
|
54
|
+
return { shouldRecall: true, reason: "wiki-intent", query: text, scopeHints: ["wiki"] }
|
|
55
|
+
}
|
|
56
|
+
if (/\b(proposal|accept|reject|diff proposal|review proposals)\b/.test(lower)) {
|
|
57
|
+
return { shouldRecall: true, reason: "proposal-intent", query: text, scopeHints: ["proposal"] }
|
|
58
|
+
}
|
|
59
|
+
if (/\b(release|publish|semver|version bump|bump version|tag the release|cut a release)\b/.test(lower)) {
|
|
60
|
+
return { shouldRecall: true, reason: "release-intent", query: text, scopeHints: ["workflow", "command"] }
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
text.length >= 16
|
|
64
|
+
&& /\b(review|pull request|\bpr\b|diff|refactor|type hints|typing|readability|readable|debug|diagnose|traceback|exception|exceptions|scaffold|unit test|tests|test|changelog|release notes|deployment|deploy|rollback|runbook|error code|healthcheck|linters|lint|format|formatting|naming conventions|convention|style guide|onboarding|new hire|new engineer|api keys|secrets|plan|design|architecture|tradeoffs|build|implement|fix|update)\b/.test(lower)
|
|
65
|
+
) {
|
|
66
|
+
return { shouldRecall: true, reason: "coding-task", query: text, scopeHints: ["code"] }
|
|
67
|
+
}
|
|
68
|
+
if (text.length >= 120) return { shouldRecall: true, reason: "long-prompt", query: text, scopeHints }
|
|
69
|
+
return { shouldRecall: false, reason: "skip-low-signal", query: text, scopeHints }
|
|
70
|
+
}
|