akm-opencode 0.9.0 → 0.9.1
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 +63 -7
- package/index.ts +298 -261
- package/package.json +1 -1
- package/shared/feedback-signals.ts +39 -0
- package/shared/memory-events.ts +7 -34
- package/shared/state-files.ts +4 -5
- package/shared/memory-candidates.ts +0 -200
package/package.json
CHANGED
|
@@ -17,6 +17,45 @@ export type AkmFeedbackSignal = {
|
|
|
17
17
|
harness: "claude-code" | "opencode"
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Retrospective feedback matchers. Both plugins classify a user message as
|
|
22
|
+
* after-the-fact praise or complaint about assets the session already touched,
|
|
23
|
+
* so the grammar lives here rather than in one harness: the two sides must
|
|
24
|
+
* agree on what counts as a signal, or the same message produces opposite
|
|
25
|
+
* verdicts depending on the harness.
|
|
26
|
+
*
|
|
27
|
+
* AKM_RETROSPECTIVE_FEEDBACK_PATTERN / AKM_RETROSPECTIVE_NEGATIVE_PATTERN let
|
|
28
|
+
* an operator retune the vocabulary (other languages, project jargon). A
|
|
29
|
+
* user-supplied pattern is untrusted input, so an invalid one falls back to the
|
|
30
|
+
* default instead of throwing at module load and taking the plugin down with
|
|
31
|
+
* it.
|
|
32
|
+
*/
|
|
33
|
+
export function createRetrospectiveFeedbackRegex(): RegExp {
|
|
34
|
+
const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
|
|
35
|
+
try {
|
|
36
|
+
return new RegExp(pattern, "i")
|
|
37
|
+
} catch {
|
|
38
|
+
return /\b(thanks|perfect|worked)\b/i
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createRetrospectiveNegativeRegex(): RegExp {
|
|
43
|
+
const pattern = process.env.AKM_RETROSPECTIVE_NEGATIVE_PATTERN ?? "\\b(wrong|failed|broken|didn't work|did not work|bad)\\b"
|
|
44
|
+
try {
|
|
45
|
+
return new RegExp(pattern, "i")
|
|
46
|
+
} catch {
|
|
47
|
+
return /\b(wrong|failed|broken|didn't work|did not work|bad)\b/i
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Explicit corrections ("that was wrong") are a stronger, narrower signal than
|
|
53
|
+
* the tunable negative matcher, so this one is deliberately not overridable.
|
|
54
|
+
*/
|
|
55
|
+
export function createExplicitCorrectionRegex(): RegExp {
|
|
56
|
+
return /\b(this was wrong|that was wrong|you were wrong|incorrect|not correct)\b/i
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
export function getAutoFeedbackMinConfidence(): number {
|
|
21
60
|
const raw = Number(process.env.AKM_AUTO_FEEDBACK_MIN_CONFIDENCE ?? "0.6")
|
|
22
61
|
return Number.isFinite(raw) ? raw : 0.6
|
package/shared/memory-events.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { redactObject } from "./redaction"
|
|
4
4
|
// Memory events log session activity (refs touched, outcomes, scope). Even
|
|
5
5
|
// post-redaction this is a privileged record, so the directory + file are
|
|
6
6
|
// locked to owner-only and events.jsonl is size-capped like every other
|
|
7
7
|
// append-only state file. Both primitives live in ./state-files so the Claude
|
|
8
|
-
// hook
|
|
8
|
+
// hook and this module share one implementation.
|
|
9
9
|
import { chmodSafe, rotateIfOversized } from "./state-files"
|
|
10
10
|
|
|
11
|
+
// Exactly the event names some shipped surface emits. The union used to carry
|
|
12
|
+
// 18 more (workflow_*, candidate_*, session_ended, ...) that no call site ever
|
|
13
|
+
// wrote — a vocabulary describing pipelines that were removed or never built.
|
|
14
|
+
// Keep this list emitter-driven: add a name when something emits it, not in
|
|
15
|
+
// anticipation.
|
|
11
16
|
export type AkmMemoryEventType =
|
|
12
17
|
| "session_started"
|
|
13
18
|
| "prompt_recall"
|
|
@@ -15,29 +20,11 @@ export type AkmMemoryEventType =
|
|
|
15
20
|
| "tool_batch_observation"
|
|
16
21
|
| "tool_ref_observed"
|
|
17
22
|
| "workflow_step"
|
|
18
|
-
| "workflow_started"
|
|
19
|
-
| "workflow_next_loaded"
|
|
20
|
-
| "workflow_step_completed"
|
|
21
|
-
| "workflow_step_blocked"
|
|
22
|
-
| "workflow_step_failed"
|
|
23
|
-
| "workflow_step_skipped"
|
|
24
|
-
| "workflow_evidence_attached"
|
|
25
|
-
| "workflow_drift_detected"
|
|
26
|
-
| "workflow_resumed"
|
|
27
|
-
| "workflow_abandoned"
|
|
28
23
|
| "task_created"
|
|
29
24
|
| "task_completed"
|
|
30
25
|
| "subagent_started"
|
|
31
|
-
| "subagent_completed"
|
|
32
|
-
| "pre_compact_checkpoint"
|
|
33
26
|
| "post_compact_summary"
|
|
34
|
-
| "session_ended"
|
|
35
|
-
| "candidate_extracted"
|
|
36
|
-
| "candidate_promoted"
|
|
37
|
-
| "candidate_rejected"
|
|
38
|
-
| "durable_memory_written"
|
|
39
27
|
| "feedback_recorded"
|
|
40
|
-
| "safety_blocked"
|
|
41
28
|
|
|
42
29
|
export type AkmMemoryEvent = {
|
|
43
30
|
version: 1
|
|
@@ -102,17 +89,3 @@ export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok
|
|
|
102
89
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
103
90
|
}
|
|
104
91
|
}
|
|
105
|
-
|
|
106
|
-
export function readJsonl<T>(filePath: string): T[] {
|
|
107
|
-
if (!existsSync(filePath)) return []
|
|
108
|
-
return readFileSync(filePath, "utf8")
|
|
109
|
-
.split("\n")
|
|
110
|
-
.filter(Boolean)
|
|
111
|
-
.flatMap((line) => {
|
|
112
|
-
try {
|
|
113
|
-
return [JSON.parse(line) as T]
|
|
114
|
-
} catch {
|
|
115
|
-
return []
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
}
|
package/shared/state-files.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared helpers for the append-only state files both plugins keep under their
|
|
3
3
|
* harness state dir (session.log, feedback.log, memory.log, quality-cache.tsv,
|
|
4
|
-
* sessions/<sid>.md, extract.log, events.jsonl
|
|
4
|
+
* sessions/<sid>.md, extract.log, events.jsonl).
|
|
5
5
|
*
|
|
6
6
|
* This module is the single copy of three primitives that were previously
|
|
7
|
-
* duplicated near-verbatim in claude/hooks/akm-hook.ts
|
|
8
|
-
* ./memory-candidates.ts:
|
|
7
|
+
* duplicated near-verbatim in claude/hooks/akm-hook.ts and ./memory-events.ts:
|
|
9
8
|
*
|
|
10
9
|
* chmodSafe() best-effort owner-only hardening that never throws
|
|
11
10
|
* atomicWriteFileSync() write-temp-then-rename, with temp cleanup on failure
|
|
@@ -43,8 +42,8 @@ export function chmodSafe(target: string, mode: number): void {
|
|
|
43
42
|
* content in full or the new content in full — never a torn/partial write.
|
|
44
43
|
*
|
|
45
44
|
* A failed rename removes the temp file (nothing else ever prunes it) and then
|
|
46
|
-
* rethrows, so
|
|
47
|
-
*
|
|
45
|
+
* rethrows, so a caller keeps its own error semantics — rotateIfOversized(),
|
|
46
|
+
* the only caller today, swallows it.
|
|
48
47
|
*/
|
|
49
48
|
export function atomicWriteFileSync(filePath: string, content: string, mode?: number): void {
|
|
50
49
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import { redactObject } from "./redaction"
|
|
4
|
-
// Memory candidates can contain prompt fragments, ref names, and (despite
|
|
5
|
-
// redaction) potentially sensitive contextual data harvested from session
|
|
6
|
-
// activity. The on-disk file is locked to user-only read/write so multi-user
|
|
7
|
-
// hosts (CI runners, shared VMs, dev sandboxes) cannot side-read another
|
|
8
|
-
// user's stash signals, and memory-candidates.jsonl is size-capped like every
|
|
9
|
-
// other append-only state file. Both primitives — plus the temp+rename write
|
|
10
|
-
// used by replaceCandidates() (13: "Non-atomic candidate updates") — live in
|
|
11
|
-
// ./state-files so the Claude hook, ./memory-events and this module share one
|
|
12
|
-
// implementation.
|
|
13
|
-
import { atomicWriteFileSync, chmodSafe, rotateIfOversized } from "./state-files"
|
|
14
|
-
|
|
15
|
-
export type AkmMemoryCandidate = {
|
|
16
|
-
id: string
|
|
17
|
-
createdAt: string
|
|
18
|
-
harness: "claude-code" | "opencode"
|
|
19
|
-
sessionId?: string
|
|
20
|
-
sourceEventIds?: string[]
|
|
21
|
-
sourcePaths?: string[]
|
|
22
|
-
type:
|
|
23
|
-
| "preference"
|
|
24
|
-
| "constraint"
|
|
25
|
-
| "decision"
|
|
26
|
-
| "lesson"
|
|
27
|
-
| "workflow_state"
|
|
28
|
-
| "asset_feedback"
|
|
29
|
-
| "coverage_gap"
|
|
30
|
-
| "stale_memory"
|
|
31
|
-
| "unknown"
|
|
32
|
-
scope: "user" | "project" | "repo" | "branch" | "session" | "agent" | "workflow"
|
|
33
|
-
content: string
|
|
34
|
-
evidence: string[]
|
|
35
|
-
confidence: number
|
|
36
|
-
recommendedAction: "remember" | "distill" | "propose" | "feedback" | "ignore"
|
|
37
|
-
targetRef?: string
|
|
38
|
-
status: "pending" | "promoted" | "rejected"
|
|
39
|
-
reason?: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const AKM_REF_RE = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|env|secret|wiki|lesson):[A-Za-z0-9._/-]+/g
|
|
43
|
-
|
|
44
|
-
function uniq(values: string[]): string[] {
|
|
45
|
-
return [...new Set(values)]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function pickTargetRef(refs: string[]): string | undefined {
|
|
49
|
-
return refs.find((ref) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:")) ?? refs[0]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function extractRefs(value: string): string[] {
|
|
53
|
-
return uniq(value.match(AKM_REF_RE) ?? [])
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function getCandidateLogPath(harness: "claude-code" | "opencode"): string {
|
|
57
|
-
const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
|
|
58
|
-
const dir = path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
|
|
59
|
-
return path.join(dir, "memory-candidates.jsonl")
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function makeCandidateId(harness: string, sessionId: string | undefined, index: number): string {
|
|
63
|
-
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)
|
|
64
|
-
const sid = (sessionId ?? "session").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 12) || "session"
|
|
65
|
-
return `${harness}-${sid}-${stamp}-${index}`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function classifyCandidate(
|
|
69
|
-
content: string,
|
|
70
|
-
refs: string[],
|
|
71
|
-
): Pick<AkmMemoryCandidate, "type" | "scope" | "confidence" | "recommendedAction" | "targetRef"> {
|
|
72
|
-
const text = content.toLowerCase()
|
|
73
|
-
const targetRef = pickTargetRef(refs)
|
|
74
|
-
if (targetRef && /\b(worked|helped|useful|failed|broken|wrong|didn't work|did not work)\b/.test(text)) {
|
|
75
|
-
return { type: "asset_feedback", scope: "project", confidence: 0.72, recommendedAction: "feedback", targetRef }
|
|
76
|
-
}
|
|
77
|
-
if (/\b(always|never|must|should not|cannot|can't|do not|required)\b/.test(text)) {
|
|
78
|
-
return { type: "constraint", scope: "project", confidence: 0.8, recommendedAction: "remember" }
|
|
79
|
-
}
|
|
80
|
-
if (/\b(prefer|likes|dislikes|wants|remember)\b/.test(text)) {
|
|
81
|
-
return { type: "preference", scope: "user", confidence: 0.8, recommendedAction: "remember" }
|
|
82
|
-
}
|
|
83
|
-
if (/\b(decided|decision|use |chosen|architecture|approach)\b/.test(text)) {
|
|
84
|
-
return { type: "decision", scope: "project", confidence: 0.75, recommendedAction: "remember" }
|
|
85
|
-
}
|
|
86
|
-
if (/\b(blocked|failed|workflow|next step|resume)\b/.test(text)) {
|
|
87
|
-
return { type: "workflow_state", scope: "workflow", confidence: 0.7, recommendedAction: "remember" }
|
|
88
|
-
}
|
|
89
|
-
if (/\b(missing|coverage gap|todo|follow-up|followup)\b/.test(text)) {
|
|
90
|
-
return { type: "coverage_gap", scope: "project", confidence: 0.65, recommendedAction: "propose" }
|
|
91
|
-
}
|
|
92
|
-
if (/\b(lesson|fix|worked|resolved|solution)\b/.test(text)) {
|
|
93
|
-
return { type: "lesson", scope: "project", confidence: 0.7, recommendedAction: "distill", targetRef }
|
|
94
|
-
}
|
|
95
|
-
return { type: "unknown", scope: "session", confidence: 0.4, recommendedAction: "ignore" }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function extractCandidatesFromText(input: {
|
|
99
|
-
harness: "claude-code" | "opencode"
|
|
100
|
-
sessionId?: string
|
|
101
|
-
text: string
|
|
102
|
-
evidence?: string[]
|
|
103
|
-
sourceEventIds?: string[]
|
|
104
|
-
sourcePaths?: string[]
|
|
105
|
-
targetRefHints?: string[]
|
|
106
|
-
}): AkmMemoryCandidate[] {
|
|
107
|
-
const lines = input.text
|
|
108
|
-
.split(/\r?\n/)
|
|
109
|
-
.map((line) => line.trim())
|
|
110
|
-
.filter((line) => line.length >= 20)
|
|
111
|
-
.filter((line) => !line.startsWith("#") && !line.startsWith("## "))
|
|
112
|
-
|
|
113
|
-
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))
|
|
114
|
-
return selected.slice(0, 12).map((content, index) => {
|
|
115
|
-
const contentRefs = extractRefs(content)
|
|
116
|
-
const evidenceRefs = (input.evidence ?? []).flatMap(extractRefs)
|
|
117
|
-
const hintedRefs = input.targetRefHints ?? []
|
|
118
|
-
const refs = uniq([
|
|
119
|
-
...contentRefs,
|
|
120
|
-
...hintedRefs,
|
|
121
|
-
...evidenceRefs,
|
|
122
|
-
])
|
|
123
|
-
const details = classifyCandidate(content, refs)
|
|
124
|
-
const targetRef = details.targetRef ?? pickTargetRef([...contentRefs, ...hintedRefs, ...evidenceRefs])
|
|
125
|
-
return {
|
|
126
|
-
id: makeCandidateId(input.harness, input.sessionId, index + 1),
|
|
127
|
-
createdAt: new Date().toISOString(),
|
|
128
|
-
harness: input.harness,
|
|
129
|
-
sessionId: input.sessionId,
|
|
130
|
-
sourceEventIds: input.sourceEventIds,
|
|
131
|
-
sourcePaths: input.sourcePaths ? uniq(input.sourcePaths) : undefined,
|
|
132
|
-
type: details.type,
|
|
133
|
-
scope: details.scope,
|
|
134
|
-
content,
|
|
135
|
-
evidence: uniq([...(input.evidence ?? []), content]),
|
|
136
|
-
confidence: details.confidence,
|
|
137
|
-
recommendedAction: details.recommendedAction,
|
|
138
|
-
targetRef,
|
|
139
|
-
status: "pending",
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function appendCandidates(filePath: string, candidates: AkmMemoryCandidate[]): { ok: true; count: number; categories: string[] } | { ok: false; error: string } {
|
|
145
|
-
try {
|
|
146
|
-
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
147
|
-
chmodSafe(path.dirname(filePath), 0o700)
|
|
148
|
-
rotateIfOversized(filePath)
|
|
149
|
-
const categories: string[] = []
|
|
150
|
-
const created = !existsSync(filePath)
|
|
151
|
-
for (const candidate of candidates) {
|
|
152
|
-
const redacted = redactObject(candidate)
|
|
153
|
-
categories.push(...redacted.categories)
|
|
154
|
-
appendFileSync(filePath, `${JSON.stringify(redacted.value)}\n`)
|
|
155
|
-
}
|
|
156
|
-
if (created) chmodSafe(filePath, 0o600)
|
|
157
|
-
return { ok: true, count: candidates.length, categories: [...new Set(categories)] }
|
|
158
|
-
} catch (error: unknown) {
|
|
159
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function readCandidates(filePath: string): AkmMemoryCandidate[] {
|
|
164
|
-
if (!existsSync(filePath)) return []
|
|
165
|
-
return readFileSync(filePath, "utf8")
|
|
166
|
-
.split("\n")
|
|
167
|
-
.filter(Boolean)
|
|
168
|
-
.flatMap((line) => {
|
|
169
|
-
try {
|
|
170
|
-
return [JSON.parse(line) as AkmMemoryCandidate]
|
|
171
|
-
} catch {
|
|
172
|
-
return []
|
|
173
|
-
}
|
|
174
|
-
})
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export function replaceCandidates(filePath: string, candidates: AkmMemoryCandidate[]): void {
|
|
178
|
-
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
179
|
-
chmodSafe(path.dirname(filePath), 0o700)
|
|
180
|
-
// Atomic rewrite (13: "Non-atomic candidate updates" — see
|
|
181
|
-
// atomicWriteFileSync above). updateCandidateStatus() is a
|
|
182
|
-
// read-modify-write over the whole file; without temp+rename, a second
|
|
183
|
-
// hook process's appendCandidates() (a plain appendFileSync) landing
|
|
184
|
-
// between this read and this write would be silently overwritten by this
|
|
185
|
-
// rewrite once it lands, because writeFileSync truncates in place.
|
|
186
|
-
// temp+rename doesn't fully eliminate that read-modify-write race (true
|
|
187
|
-
// fix would need a lock), but it does guarantee the file itself is never
|
|
188
|
-
// observed half-written / truncated by a concurrent reader.
|
|
189
|
-
atomicWriteFileSync(filePath, candidates.map((candidate) => `${JSON.stringify(candidate)}\n`).join(""), 0o600)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function updateCandidateStatus(filePath: string, id: string, status: "promoted" | "rejected", reason?: string): AkmMemoryCandidate | undefined {
|
|
193
|
-
const candidates = readCandidates(filePath)
|
|
194
|
-
const index = candidates.findIndex((candidate) => candidate.id === id)
|
|
195
|
-
if (index === -1) return undefined
|
|
196
|
-
const updated = { ...candidates[index], status, reason }
|
|
197
|
-
candidates[index] = updated
|
|
198
|
-
replaceCandidates(filePath, candidates)
|
|
199
|
-
return updated
|
|
200
|
-
}
|