kifaru 1.0.158 → 1.0.161

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 opencode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * kifaru-coverage-tracker.ts — pi extension: (Asset × VulnClass) coverage.
3
+ *
4
+ * Machine-readable counterpart of the kifaru-coverage-method skill: keeps a
5
+ * durable coverage ledger (terminal states TESTED_SAFE / VERIFIED / GHOST /
6
+ * JUSTIFIED_BLOCKED, non-terminal PENDING / OBSTRUCTED), records blocked-cell
7
+ * justifications, and reports what is still open — so hunts cannot silently
8
+ * skip cells and completion claims are honest.
9
+ *
10
+ * State persists through pi.appendEntry (custom entries never enter model
11
+ * context) and is restored on session_start. Loaded automatically from
12
+ * .pi/extensions/ (project scope) or via `-e`.
13
+ *
14
+ * Surface:
15
+ * - tool kifaru_coverage { action: "set"|"block"|"list"|"open", ... }
16
+ * - command /kifaru-coverage [list|open]
17
+ */
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
19
+ import { Type } from "typebox"
20
+
21
+ export type CoverageState = "TESTED_SAFE" | "VERIFIED" | "GHOST" | "JUSTIFIED_BLOCKED" | "PENDING" | "OBSTRUCTED"
22
+ const TERMINAL: CoverageState[] = ["TESTED_SAFE", "VERIFIED", "GHOST", "JUSTIFIED_BLOCKED"]
23
+
24
+ export interface CoverageCell {
25
+ key: string // `${asset}::${vulnClass}`
26
+ asset: string
27
+ vulnClass: string
28
+ state: CoverageState
29
+ evidence: string // the decisive test / proof / probe / justification
30
+ updatedAt: number
31
+ }
32
+
33
+ let cells: CoverageCell[] = []
34
+
35
+ function cellKey(asset: string, vulnClass: string): string {
36
+ return `${asset.trim().toLowerCase()}::${vulnClass.trim().toLowerCase()}`
37
+ }
38
+
39
+ function upsert(cell: Omit<CoverageCell, "key" | "updatedAt">): CoverageCell {
40
+ const key = cellKey(cell.asset, cell.vulnClass)
41
+ const existing = cells.find((c) => c.key === key)
42
+ if (existing) {
43
+ existing.state = cell.state
44
+ existing.evidence = cell.evidence
45
+ existing.updatedAt = Date.now()
46
+ return existing
47
+ }
48
+ const next: CoverageCell = { key, ...cell, updatedAt: Date.now() }
49
+ cells.push(next)
50
+ return next
51
+ }
52
+
53
+ function render(onlyOpen: boolean): string {
54
+ const open = cells.filter((c) => !TERMINAL.includes(c.state))
55
+ const list = onlyOpen ? open : cells
56
+ const lines = []
57
+ lines.push(`Coverage ledger: ${cells.length} cells (${open.length} open / non-terminal)`)
58
+ for (const c of list) {
59
+ lines.push(` [${c.state}] ${c.asset} × ${c.vulnClass}${c.evidence ? ` — ${c.evidence.slice(0, 140)}` : ""}`)
60
+ }
61
+ if (onlyOpen && open.length === 0) lines.push(" (no open cells — all terminal)")
62
+ return lines.join("\n")
63
+ }
64
+
65
+ export default function (pi: ExtensionAPI) {
66
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
67
+ cells = []
68
+ for (const entry of ctx.sessionManager.getEntries()) {
69
+ if (entry.type === "custom" && entry.customType === "kifaru-coverage" && Array.isArray(entry.data?.cells)) {
70
+ cells = entry.data.cells as CoverageCell[]
71
+ }
72
+ }
73
+ })
74
+
75
+ const persist = () => pi.appendEntry("kifaru-coverage", { cells })
76
+
77
+ pi.registerTool({
78
+ name: "kifaru_coverage",
79
+ label: "Kifaru Coverage",
80
+ description:
81
+ "Track hunt coverage as (asset × vulnerability class) cells. Terminal states: TESTED_SAFE, VERIFIED, GHOST, JUSTIFIED_BLOCKED. Non-terminal: PENDING, OBSTRUCTED. Use 'block' to justify why a cell cannot be tested (never silently skip), 'list'/'open' to review the ledger. A hunt is not complete while open cells remain.",
82
+ promptSnippet: "Track (asset × vuln class) coverage cells and record honest blocked justifications",
83
+ parameters: Type.Object({
84
+ action: Type.Union([Type.Literal("set"), Type.Literal("block"), Type.Literal("list"), Type.Literal("open")]),
85
+ asset: Type.Optional(Type.String({ description: "Asset identifier (host, endpoint, repo, app)" })),
86
+ vulnClass: Type.Optional(Type.String({ description: "Vulnerability class (e.g. idor, sqli, race-condition)" })),
87
+ state: Type.Optional(
88
+ Type.Union([
89
+ Type.Literal("TESTED_SAFE"),
90
+ Type.Literal("VERIFIED"),
91
+ Type.Literal("GHOST"),
92
+ Type.Literal("JUSTIFIED_BLOCKED"),
93
+ Type.Literal("PENDING"),
94
+ Type.Literal("OBSTRUCTED"),
95
+ ]),
96
+ ),
97
+ evidence: Type.Optional(Type.String({ description: "Decisive test / minimal proof / probe / justification" })),
98
+ }),
99
+ async execute(_toolCallId, params) {
100
+ if (params.action === "list" || params.action === "open") {
101
+ return { content: [{ type: "text", text: render(params.action === "open") }], details: { ok: true } }
102
+ }
103
+ if (!params.asset || !params.vulnClass || !params.state) {
104
+ return {
105
+ content: [{ type: "text", text: "set/block require asset, vulnClass and state." }],
106
+ details: { ok: false },
107
+ }
108
+ }
109
+ const cell = upsert({
110
+ asset: params.asset,
111
+ vulnClass: params.vulnClass,
112
+ state: params.state,
113
+ evidence: params.evidence ?? "",
114
+ })
115
+ persist()
116
+ const terminal = TERMINAL.includes(cell.state)
117
+ ? "terminal"
118
+ : "NOT terminal — must be resolved, pivoted, or formally justified"
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: `Recorded [${cell.state}] ${cell.asset} × ${cell.vulnClass} (${terminal}). Open cells: ${cells.filter((c) => !TERMINAL.includes(c.state)).length}`,
124
+ },
125
+ ],
126
+ details: { ok: true },
127
+ }
128
+ },
129
+ })
130
+
131
+ pi.registerCommand("kifaru-coverage", {
132
+ description: "Show the coverage ledger (list|open)",
133
+ handler: async (args, ctx) => {
134
+ const onlyOpen = args?.trim()?.toLowerCase() === "open" || args?.trim()?.toLowerCase() === ""
135
+ pi.sendMessage({ content: render(onlyOpen), display: true, details: {} })
136
+ },
137
+ })
138
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * kifaru-cve-intel.ts — pi extension: CVE lookup + version cross-check.
3
+ *
4
+ * Provides the vulnerability-intelligence surface (opencode intelligence/
5
+ * cve-db + cve-feed) for the Pi runtime: a local curated table for the ~15
6
+ * CVEs Kifaru tooling cites by default, an optional network refresh from the
7
+ * CIRCL API (guarded by timeout; skipped when PI_OFFLINE=1), and the
8
+ * RQ-01 version-consistency cross-check reused from kifaru-report-lint.
9
+ *
10
+ * Loaded automatically from .pi/extensions/ (project scope) or via `-e`.
11
+ *
12
+ * Surface:
13
+ * - tool kifaru_cve_lookup { cveId, version?, refresh? }
14
+ * - command /kifaru-cve <CVE-ID>
15
+ */
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
17
+ import { Type } from "typebox"
18
+ import { cveVersionCheck } from "./kifaru-report-lint"
19
+
20
+ async function fetchRemoteCve(cveId: string, signal?: AbortSignal): Promise<string | null> {
21
+ if (process.env.PI_OFFLINE === "1") return null
22
+ try {
23
+ const ctrl = new AbortController()
24
+ const t = setTimeout(() => ctrl.abort(), 6000)
25
+ signal?.addEventListener("abort", () => ctrl.abort())
26
+ const res = await fetch(`https://cve.circl.lu/api/cve/${encodeURIComponent(cveId)}`, { signal: ctrl.signal })
27
+ clearTimeout(t)
28
+ if (!res.ok) return null
29
+ const data = (await res.json()) as {
30
+ id?: string
31
+ summary?: string
32
+ cvss?: number
33
+ cwe?: string
34
+ references?: string[]
35
+ } | null
36
+ if (!data?.summary) return null
37
+ return [
38
+ `CVE: ${data.id ?? cveId}`,
39
+ `summary: ${data.summary.slice(0, 400)}`,
40
+ `cvss: ${data.cvss ?? "n/a"} cwe: ${data.cwe ?? "n/a"}`,
41
+ `references: ${(data.references ?? []).slice(0, 3).join(" | ")}`,
42
+ ].join("\n")
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ export default function (pi: ExtensionAPI) {
49
+ pi.registerTool({
50
+ name: "kifaru_cve_lookup",
51
+ label: "Kifaru CVE Lookup",
52
+ description:
53
+ "Look up a CVE in the local Kifaru table or via the CIRCL API (when online), and cross-check an observed version against the affected range (RQ-01). Use before citing a CVE in a finding.",
54
+ promptSnippet: "Look up CVEs and cross-check version compatibility before citing them",
55
+ parameters: Type.Object({
56
+ cveId: Type.String({ description: "CVE identifier, e.g. CVE-2021-41773" }),
57
+ version: Type.Optional(Type.String({ description: "Observed version (banner/manifest) to cross-check" })),
58
+ refresh: Type.Optional(Type.Boolean({ description: "Fetch fresh data from the CIRCL API" })),
59
+ }),
60
+ async execute(_toolCallId, params: { cveId: string; version?: string; refresh?: boolean }, signal?: AbortSignal) {
61
+ const cveId = params.cveId.toUpperCase()
62
+ const lines: string[] = []
63
+ const local = cveVersionCheck(cveId, params.version ?? "")
64
+ if (params.version) lines.push(`version check: ${local.verdict} — ${local.reason}`)
65
+ lines.push(`local entry: ${local.name || "not in the curated table (verify against an authoritative feed)"}`)
66
+ if (params.refresh) {
67
+ const remote = await fetchRemoteCve(cveId, signal)
68
+ if (remote) {
69
+ lines.push("")
70
+ lines.push(remote)
71
+ } else {
72
+ lines.push("remote lookup unavailable (offline or API error); local entry shown only.")
73
+ }
74
+ }
75
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { ok: true } }
76
+ },
77
+ })
78
+
79
+ pi.registerCommand("kifaru-cve", {
80
+ description: "Look up a CVE (local table + optional CIRCL refresh)",
81
+ handler: async (args, ctx) => {
82
+ const cveId = (args ?? "").trim().toUpperCase()
83
+ if (!/^CVE-\d{4}-\d{4,7}$/.test(cveId)) {
84
+ ctx.ui.notify("usage: /kifaru-cve CVE-YYYY-NNNNN", "warning")
85
+ return
86
+ }
87
+ const local = cveVersionCheck(cveId, "")
88
+ const remote = await fetchRemoteCve(cveId)
89
+ pi.sendMessage({ content: [local.reason, remote ?? ""].filter(Boolean).join("\n"), display: true, details: {} })
90
+ },
91
+ })
92
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * kifaru-dossier.ts — pi extension: durable target dossier (observations + findings).
3
+ *
4
+ * Port of the opencode target_dossier machinery for the Pi runtime:
5
+ * - persistence/target-dossier.ts (TargetDossier shape, merge-dedupe updates)
6
+ * - tool/target-dossier.ts (load|update surface)
7
+ * - tool/hunt-dossier.ts list_targets (target roll-up)
8
+ *
9
+ * The dossier JSON keeps the exact opencode `TargetDossier` shape
10
+ * ({ id, target, observations, findings, lastUpdated }) so either runtime can
11
+ * consume the same record. Storage is project-scoped at
12
+ * `.kifaru/dossiers/<slug>.json` (gitignored, cross-session, cross-runtime)
13
+ * instead of the opencode XDG data dir — the Pi agent is the primary run
14
+ * channel, and hunt state already lives under this repo's `.kifaru/`.
15
+ *
16
+ * Use for preserving target context, findings, and handoff notes across turns.
17
+ * Do not use for live reconnaissance or vulnerability lookup.
18
+ *
19
+ * Loaded automatically from .pi/extensions/ (project scope) or via `-e`.
20
+ *
21
+ * Session binding (mirrors opencode maybeHandleHuntBind): the active target is
22
+ * resolved at session start from KIFARU_TUI_TARGET (set at tui launch), or
23
+ * detected from the user's first prompt when no target is bound; the dossier is
24
+ * then bound and a compact dossier context block is injected into the system
25
+ * prompt once per target. The kifaru_dossier tool/command can re-bind anytime.
26
+ *
27
+ * Surface:
28
+ * - tool kifaru_dossier { action: "load"|"update"|"list", target?, observations?, findings? }
29
+ * - command /kifaru-dossier [bind <target>|<target>|list|status]
30
+ */
31
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
32
+ import fs from "node:fs/promises"
33
+ import path from "node:path"
34
+ import { Type } from "typebox"
35
+
36
+ export interface TargetDossier {
37
+ id: string
38
+ target: string
39
+ observations: string[]
40
+ findings: string[]
41
+ lastUpdated: number
42
+ }
43
+
44
+ /** Port of opencode persistence/hunt-dossier `getTargetSlug` (host normalization). */
45
+ export function targetSlug(target: string): string {
46
+ const raw = target.trim()
47
+ if (raw.length === 0) return "unknown"
48
+
49
+ const clean = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "")
50
+ const host = clean.split("/")[0] ?? clean
51
+ const lower = host.toLowerCase()
52
+ const parts = lower.split(":")
53
+ const name = parts[0] ?? lower
54
+ const port = parts[1]
55
+
56
+ if (name === "localhost") {
57
+ if (port && port.length > 0) return `localhost-${port}`
58
+ return "localhost"
59
+ }
60
+
61
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(name)
62
+ if (isIp) return name
63
+
64
+ const safe = name.replace(/[^a-z0-9._-]/g, "-")
65
+ return safe.length > 0 ? safe : "unknown"
66
+ }
67
+
68
+ function dossierFile(target: string): string {
69
+ return path.join(process.cwd(), ".kifaru", "dossiers", `${targetSlug(target)}.json`)
70
+ }
71
+
72
+ function emptyDossier(target: string): TargetDossier {
73
+ return { id: target, target: target.trim(), observations: [], findings: [], lastUpdated: Date.now() }
74
+ }
75
+
76
+ async function loadDossier(target: string): Promise<TargetDossier> {
77
+ const file = dossierFile(target)
78
+ const data = await fs
79
+ .readFile(file, "utf8")
80
+ .then((text) => JSON.parse(text) as Partial<TargetDossier>)
81
+ .catch(() => null)
82
+ if (!data || typeof data !== "object") return emptyDossier(target)
83
+ return {
84
+ id: target,
85
+ target: target.trim(),
86
+ observations: Array.isArray(data.observations) ? data.observations.filter((s) => typeof s === "string") : [],
87
+ findings: Array.isArray(data.findings) ? data.findings.filter((s) => typeof s === "string") : [],
88
+ lastUpdated: Date.now(),
89
+ }
90
+ }
91
+
92
+ /** port of opencode mergeList: skip empties, dedupe, preserve existing order. */
93
+ function mergeList(base: string[], extra: string[] | undefined): string[] {
94
+ const list = extra ?? []
95
+ if (list.length === 0) return base
96
+ const set = new Set(base)
97
+ for (const item of list) {
98
+ if (typeof item !== "string" || item.trim().length === 0) continue
99
+ set.add(item.trim())
100
+ }
101
+ return [...set]
102
+ }
103
+
104
+ async function updateDossier(input: {
105
+ target: string
106
+ observations?: string[]
107
+ findings?: string[]
108
+ }): Promise<TargetDossier> {
109
+ const current = await loadDossier(input.target)
110
+ const next: TargetDossier = {
111
+ ...current,
112
+ observations: mergeList(current.observations, input.observations),
113
+ findings: mergeList(current.findings, input.findings),
114
+ lastUpdated: Date.now(),
115
+ }
116
+ const file = dossierFile(input.target)
117
+ await fs.mkdir(path.dirname(file), { recursive: true })
118
+ // atomic-ish write: temp file + rename so concurrent readers never see a partial JSON
119
+ const tmp = `${file}.tmp`
120
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2), "utf8")
121
+ await fs.rename(tmp, file)
122
+ return next
123
+ }
124
+
125
+ async function listTargets(): Promise<
126
+ Array<{ slug: string; target: string; observations: number; findings: number; lastUpdated: number }>
127
+ > {
128
+ const root = path.join(process.cwd(), ".kifaru", "dossiers")
129
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
130
+ const items: Array<{ slug: string; target: string; observations: number; findings: number; lastUpdated: number }> = []
131
+ for (const entry of entries) {
132
+ if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.endsWith(".tmp")) continue
133
+ const data = await fs
134
+ .readFile(path.join(root, entry.name), "utf8")
135
+ .then((text) => JSON.parse(text) as Partial<TargetDossier>)
136
+ .catch(() => null)
137
+ if (!data) continue
138
+ items.push({
139
+ slug: entry.name.replace(/\.json$/, ""),
140
+ target: typeof data.target === "string" ? data.target : entry.name.replace(/\.json$/, ""),
141
+ observations: Array.isArray(data.observations) ? data.observations.length : 0,
142
+ findings: Array.isArray(data.findings) ? data.findings.length : 0,
143
+ lastUpdated: typeof data.lastUpdated === "number" ? data.lastUpdated : 0,
144
+ })
145
+ }
146
+ return items.sort((a, b) => b.lastUpdated - a.lastUpdated)
147
+ }
148
+
149
+ // ---- session binding state (mirrors opencode session/prompt.ts HuntBindState) ----
150
+
151
+ const HUNT_URL_REGEX = /https?:\/\/[^\s<>")']+/gi
152
+ const HUNT_DOMAIN_REGEX = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi
153
+
154
+ let boundTarget: string | null = null
155
+ const injectedTargets = new Set<string>()
156
+
157
+ function envTarget(): string | null {
158
+ const value = process.env.KIFARU_TUI_TARGET ?? process.env.KIFARU_TARGET ?? ""
159
+ return value.trim().length > 0 ? value.trim() : null
160
+ }
161
+
162
+ /** Port of opencode detectTarget: first URL, else first domain. */
163
+ function detectTarget(text: string): string | null {
164
+ const urls = text.match(HUNT_URL_REGEX)
165
+ if (urls && urls.length > 0) {
166
+ const url = urls[0]!.trim().replace(/[),.!?;:]$/, "")
167
+ if (url.length > 0 && url.length < 512) return url
168
+ }
169
+ const domains = text.match(HUNT_DOMAIN_REGEX)
170
+ if (domains && domains.length > 0) {
171
+ const domain = domains[0]!.trim()
172
+ if (domain.length > 0 && domain.length < 256) return domain
173
+ }
174
+ return null
175
+ }
176
+
177
+ function dossierContextBlock(target: string, dossier: TargetDossier): string {
178
+ const lines = [
179
+ `<kifaru_dossier target="${target}">`,
180
+ `Bound target dossier: ${dossier.observations.length} observation(s), ${dossier.findings.length} finding(s).`,
181
+ `Dossier file: ${path.join(process.cwd(), ".kifaru", "dossiers", `${targetSlug(target)}.json`)}`,
182
+ ]
183
+ const recentObservations = dossier.observations.slice(-5)
184
+ if (recentObservations.length > 0) {
185
+ lines.push(`Recent observations:`)
186
+ for (const obs of recentObservations) lines.push(` - ${obs.slice(0, 240)}`)
187
+ }
188
+ const recentFindings = dossier.findings.slice(-3)
189
+ if (recentFindings.length > 0) {
190
+ lines.push(`Recent findings:`)
191
+ for (const finding of recentFindings) lines.push(` - ${finding.slice(0, 240)}`)
192
+ }
193
+ lines.push(`Use kifaru_dossier { action: "update", ... } to persist new context; keep entries concise.`)
194
+ lines.push(`</kifaru_dossier>`)
195
+ return lines.join("\n")
196
+ }
197
+
198
+ export default function (pi: ExtensionAPI) {
199
+ pi.on("session_start", async () => {
200
+ boundTarget = envTarget()
201
+ injectedTargets.clear()
202
+ })
203
+
204
+ // Bind the target from the first prompt (opencode hunt-bind interception) and
205
+ // inject the dossier context once per target — before_agent_start only.
206
+ pi.on("before_agent_start", async (event) => {
207
+ const candidate = boundTarget ?? envTarget() ?? detectTarget(event.prompt ?? "")
208
+ if (!candidate || injectedTargets.has(candidate)) return undefined
209
+ boundTarget = candidate
210
+ injectedTargets.add(candidate)
211
+ const dossier = await updateDossier({ target: candidate })
212
+ pi.appendEntry("kifaru-dossier", {
213
+ target: candidate,
214
+ observations: dossier.observations.length,
215
+ findings: dossier.findings.length,
216
+ })
217
+ return {
218
+ systemPrompt: `${event.systemPrompt}\n\n${dossierContextBlock(candidate, dossier)}`,
219
+ message: {
220
+ customType: "kifaru-dossier",
221
+ content: `Session bound to target ${candidate}. Dossier created/loaded under .kifaru/dossiers/.`,
222
+ display: false,
223
+ },
224
+ }
225
+ })
226
+
227
+ pi.registerTool({
228
+ name: "kifaru_dossier",
229
+ label: "Kifaru Target Dossier",
230
+ description:
231
+ "Load or update the persistent target dossier (observations + findings), or list all dossiers. Use to preserve target context, findings, and handoff notes across turns. Not for live reconnaissance or vulnerability lookup.",
232
+ promptSnippet: "Persist and reload target observations and findings across turns",
233
+ parameters: Type.Object({
234
+ action: Type.Union([Type.Literal("load"), Type.Literal("update"), Type.Literal("list")]),
235
+ target: Type.Optional(Type.String({ description: "Target identifier (host, app, repo, api base url)" })),
236
+ observations: Type.Optional(Type.Array(Type.String(), { description: "Observations to add (merged, deduped)" })),
237
+ findings: Type.Optional(Type.Array(Type.String(), { description: "Findings to add (merged, deduped)" })),
238
+ }),
239
+ async execute(
240
+ _toolCallId,
241
+ params: { action: "load" | "update" | "list"; target?: string; observations?: string[]; findings?: string[] },
242
+ ) {
243
+ if (params.action === "list") {
244
+ const targets = await listTargets()
245
+ const lines = targets.length
246
+ ? targets.map(
247
+ (t) =>
248
+ ` ${t.slug} — ${t.observations} obs, ${t.findings} findings (${new Date(t.lastUpdated).toISOString()})`,
249
+ )
250
+ : [' (no dossiers yet — kifaru_dossier { action: "update", target, ... } creates one)']
251
+ return {
252
+ content: [{ type: "text", text: `Target dossiers: ${targets.length}\n${lines.join("\n")}` }],
253
+ details: { ok: true, count: targets.length },
254
+ }
255
+ }
256
+ const target = (params.target ?? "").trim()
257
+ if (!target) {
258
+ return { content: [{ type: "text", text: "load/update require a non-empty target." }], details: { ok: false } }
259
+ }
260
+ // Any explicit dossier action on a target re-binds the session to it.
261
+ boundTarget = target
262
+ if (params.action === "load") {
263
+ const dossier = await loadDossier(target)
264
+ return {
265
+ content: [
266
+ {
267
+ type: "text",
268
+ text: JSON.stringify(dossier, null, 2),
269
+ },
270
+ ],
271
+ details: { ok: true, observations: dossier.observations.length, findings: dossier.findings.length },
272
+ }
273
+ }
274
+ const dossier = await updateDossier({ target, observations: params.observations, findings: params.findings })
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: JSON.stringify(dossier, null, 2),
280
+ },
281
+ ],
282
+ details: { ok: true, observations: dossier.observations.length, findings: dossier.findings.length },
283
+ }
284
+ },
285
+ })
286
+
287
+ pi.registerCommand("kifaru-dossier", {
288
+ description: "Show/bind the target dossier (status, list, bind <target>, or a target slug/URL)",
289
+ handler: async (args, ctx) => {
290
+ const arg = (args ?? "").trim()
291
+ const lower = arg.toLowerCase()
292
+ if (lower === "status" || !arg) {
293
+ const target = boundTarget ?? envTarget()
294
+ if (!target) {
295
+ ctx.ui.notify(
296
+ "No target bound yet — start a message with a URL/domain, or use: /kifaru-dossier bind <target>",
297
+ "info",
298
+ )
299
+ return
300
+ }
301
+ const dossier = await loadDossier(target)
302
+ pi.sendMessage({
303
+ content: `Bound target: ${target}\nObservations: ${dossier.observations.length}\nFindings: ${dossier.findings.length}\nUpdated: ${new Date(dossier.lastUpdated).toISOString()}`,
304
+ display: true,
305
+ details: {},
306
+ })
307
+ return
308
+ }
309
+ if (lower === "bind") {
310
+ ctx.ui.notify("usage: /kifaru-dossier bind <target>", "warning")
311
+ return
312
+ }
313
+ if (lower.startsWith("bind ")) {
314
+ const target = arg.slice(5).trim()
315
+ if (!target) {
316
+ ctx.ui.notify("usage: /kifaru-dossier bind <target>", "warning")
317
+ return
318
+ }
319
+ boundTarget = target
320
+ const dossier = await updateDossier({ target })
321
+ ctx.ui.notify(
322
+ `Bound target dossier: ${target} (${dossier.observations.length} obs, ${dossier.findings.length} findings)`,
323
+ "info",
324
+ )
325
+ return
326
+ }
327
+ if (lower === "list") {
328
+ const targets = await listTargets()
329
+ const body = targets.length
330
+ ? targets.map((t) => `- ${t.slug} (${t.observations} obs, ${t.findings} findings)`).join("\n")
331
+ : "No dossiers yet."
332
+ pi.sendMessage({ content: `Target dossiers:\n${body}`, display: true, details: {} })
333
+ return
334
+ }
335
+ const dossier = await loadDossier(arg)
336
+ const body = [
337
+ `Target: ${dossier.target}`,
338
+ `Observations (${dossier.observations.length}):`,
339
+ ...dossier.observations.map((o) => ` - ${o}`),
340
+ `Findings (${dossier.findings.length}):`,
341
+ ...dossier.findings.map((f) => ` - ${f}`),
342
+ `Last updated: ${new Date(dossier.lastUpdated).toISOString()}`,
343
+ ].join("\n")
344
+ pi.sendMessage({ content: body, display: true, details: {} })
345
+ },
346
+ })
347
+ }