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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for AKM 0.9 with in-process search, show, and curate tools plus feedback and remember.",
6
6
  "keywords": [
@@ -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
@@ -1,13 +1,18 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
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, this module and ./memory-candidates share one implementation.
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
- }
@@ -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, memory-candidates.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, ./memory-events.ts and
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 each caller keeps its own error semantics: rotateIfOversized()
47
- * swallows the error, memory-candidates' replaceCandidates() propagates it.
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
- }