kibi-opencode 0.8.0 → 0.10.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 +37 -12
- package/dist/brief-intent.d.ts +41 -0
- package/dist/brief-intent.js +127 -0
- package/dist/briefing-runtime.d.ts +24 -0
- package/dist/briefing-runtime.js +277 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +9 -0
- package/dist/e2e-coverage-signals.d.ts +6 -0
- package/dist/e2e-coverage-signals.js +186 -0
- package/dist/file-entity-links.d.ts +15 -0
- package/dist/file-entity-links.js +254 -0
- package/dist/file-operation-reminders.d.ts +24 -0
- package/dist/file-operation-reminders.js +55 -0
- package/dist/file-operation-state.d.ts +29 -0
- package/dist/file-operation-state.js +113 -0
- package/dist/idle-brief-audit.d.ts +36 -0
- package/dist/idle-brief-audit.js +186 -0
- package/dist/idle-brief-paths.d.ts +6 -0
- package/dist/idle-brief-paths.js +120 -0
- package/dist/idle-brief-reader.d.ts +25 -0
- package/dist/idle-brief-reader.js +142 -0
- package/dist/idle-brief-runtime.d.ts +48 -0
- package/dist/idle-brief-runtime.js +443 -0
- package/dist/idle-brief-store.d.ts +96 -0
- package/dist/idle-brief-store.js +209 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +645 -22
- package/dist/init-kibi-alias.d.ts +14 -0
- package/dist/init-kibi-alias.js +38 -0
- package/dist/init-kibi-capability.d.ts +32 -0
- package/dist/init-kibi-capability.js +202 -0
- package/dist/logger.js +9 -3
- package/dist/plugin-startup.d.ts +1 -0
- package/dist/plugin-startup.js +11 -2
- package/dist/prompt.d.ts +18 -3
- package/dist/prompt.js +176 -50
- package/dist/reconcile-engine.d.ts +15 -0
- package/dist/reconcile-engine.js +112 -0
- package/dist/scheduler.d.ts +1 -0
- package/dist/scheduler.js +37 -1
- package/dist/session-edit-state.d.ts +25 -0
- package/dist/session-edit-state.js +177 -0
- package/dist/session-fingerprint.d.ts +11 -0
- package/dist/session-fingerprint.js +21 -0
- package/dist/source-linked-guidance.d.ts +1 -2
- package/dist/source-linked-guidance.js +5 -168
- package/dist/startup-notifier.d.ts +3 -18
- package/dist/startup-notifier.js +42 -36
- package/dist/toast.d.ts +31 -0
- package/dist/toast.js +40 -0
- package/dist/tui-brief-delivery.d.ts +47 -0
- package/dist/tui-brief-delivery.js +138 -0
- package/package.json +4 -3
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const SENTINEL_HASH = "<deleted>";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Implementation
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export function createSessionEditState(opts) {
|
|
12
|
+
const worktree = opts.worktree;
|
|
13
|
+
const now = opts.now ?? Date.now;
|
|
14
|
+
// ---- Per-instance state (no module globals) ----
|
|
15
|
+
/**
|
|
16
|
+
* Tracked files keyed by relative path.
|
|
17
|
+
* Undefined baselineHash means we haven't taken a snapshot yet.
|
|
18
|
+
*/
|
|
19
|
+
const tracked = new Map();
|
|
20
|
+
// ---- Internal helpers ----
|
|
21
|
+
function resolveToRelative(filePath) {
|
|
22
|
+
if (path.isAbsolute(filePath)) {
|
|
23
|
+
const rel = path.relative(worktree, filePath);
|
|
24
|
+
// Normalise away any leading ./ or ../ that escapes worktree
|
|
25
|
+
return rel.startsWith("..") ? filePath : rel;
|
|
26
|
+
}
|
|
27
|
+
return filePath;
|
|
28
|
+
}
|
|
29
|
+
function resolveToAbsolute(relPath) {
|
|
30
|
+
return path.join(worktree, relPath);
|
|
31
|
+
}
|
|
32
|
+
function hashContent(content) {
|
|
33
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function hashFile(absPath) {
|
|
36
|
+
try {
|
|
37
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
38
|
+
return hashContent(content);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return SENTINEL_HASH;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Take a baseline snapshot if we haven't yet.
|
|
46
|
+
* Returns the baseline hash.
|
|
47
|
+
*/
|
|
48
|
+
function ensureBaseline(entry, relPath) {
|
|
49
|
+
if (entry.baselineHash !== undefined) {
|
|
50
|
+
return entry.baselineHash;
|
|
51
|
+
}
|
|
52
|
+
const abs = resolveToAbsolute(relPath);
|
|
53
|
+
const h = hashFile(abs);
|
|
54
|
+
entry.baselineHash = h;
|
|
55
|
+
return h;
|
|
56
|
+
}
|
|
57
|
+
// ---- Public API ----
|
|
58
|
+
function recordEventHint(filePath, kind, timestamp) {
|
|
59
|
+
const rel = resolveToRelative(filePath);
|
|
60
|
+
let entry = tracked.get(rel);
|
|
61
|
+
if (!entry) {
|
|
62
|
+
entry = {
|
|
63
|
+
baselineHash: undefined,
|
|
64
|
+
currentHash: undefined,
|
|
65
|
+
lastReconciledAt: 0,
|
|
66
|
+
eventHints: [],
|
|
67
|
+
};
|
|
68
|
+
tracked.set(rel, entry);
|
|
69
|
+
}
|
|
70
|
+
entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
|
|
71
|
+
}
|
|
72
|
+
function reconcilePath(filePath) {
|
|
73
|
+
const rel = resolveToRelative(filePath);
|
|
74
|
+
let entry = tracked.get(rel);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = {
|
|
77
|
+
baselineHash: undefined,
|
|
78
|
+
currentHash: undefined,
|
|
79
|
+
lastReconciledAt: 0,
|
|
80
|
+
eventHints: [],
|
|
81
|
+
};
|
|
82
|
+
tracked.set(rel, entry);
|
|
83
|
+
}
|
|
84
|
+
// Lazy baseline snapshot
|
|
85
|
+
ensureBaseline(entry, rel);
|
|
86
|
+
// Current hash
|
|
87
|
+
const abs = resolveToAbsolute(rel);
|
|
88
|
+
const current = hashFile(abs);
|
|
89
|
+
entry.currentHash = current;
|
|
90
|
+
entry.lastReconciledAt = now();
|
|
91
|
+
}
|
|
92
|
+
function reconcileKnownPaths() {
|
|
93
|
+
for (const relPath of tracked.keys()) {
|
|
94
|
+
reconcilePath(relPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Return surviving session edits: files whose current hash differs from baseline.
|
|
99
|
+
* Sorted by lastReconciledAt ascending (oldest first).
|
|
100
|
+
*/
|
|
101
|
+
function getSessionEdits() {
|
|
102
|
+
const results = [];
|
|
103
|
+
for (const [relPath, entry] of tracked) {
|
|
104
|
+
if (entry.baselineHash === undefined || entry.currentHash === undefined) {
|
|
105
|
+
// Not yet reconciled
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.currentHash !== entry.baselineHash) {
|
|
109
|
+
results.push({
|
|
110
|
+
filePath: relPath,
|
|
111
|
+
baselineHash: entry.baselineHash,
|
|
112
|
+
currentHash: entry.currentHash,
|
|
113
|
+
lastReconciledAt: entry.lastReconciledAt,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
results.sort((a, b) => a.lastReconciledAt - b.lastReconciledAt);
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Focus edit = the last reconciled surviving edit (highest lastReconciledAt).
|
|
122
|
+
*/
|
|
123
|
+
function getFocusEdit() {
|
|
124
|
+
const edits = getSessionEdits();
|
|
125
|
+
if (edits.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
// edits are sorted ascending by lastReconciledAt, so last = most recent
|
|
128
|
+
return edits[edits.length - 1];
|
|
129
|
+
}
|
|
130
|
+
function hasSessionEdits() {
|
|
131
|
+
for (const [, entry] of tracked) {
|
|
132
|
+
if (entry.baselineHash !== undefined &&
|
|
133
|
+
entry.currentHash !== undefined &&
|
|
134
|
+
entry.currentHash !== entry.baselineHash) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Force a file to be treated as a session edit without requiring a prior baseline.
|
|
142
|
+
* Used for eventless edits where the host signals a change via transform hook
|
|
143
|
+
* but no file.edited event was emitted to establish a pre-change baseline.
|
|
144
|
+
*/
|
|
145
|
+
function forceEdit(filePath, kind, timestamp) {
|
|
146
|
+
const rel = resolveToRelative(filePath);
|
|
147
|
+
let entry = tracked.get(rel);
|
|
148
|
+
if (!entry) {
|
|
149
|
+
entry = {
|
|
150
|
+
baselineHash: undefined,
|
|
151
|
+
currentHash: undefined,
|
|
152
|
+
lastReconciledAt: 0,
|
|
153
|
+
eventHints: [],
|
|
154
|
+
};
|
|
155
|
+
tracked.set(rel, entry);
|
|
156
|
+
}
|
|
157
|
+
// Set a synthetic baseline that will never match real file content
|
|
158
|
+
if (entry.baselineHash === undefined) {
|
|
159
|
+
entry.baselineHash = hashContent(`__FORCED_BASELINE__${rel}`);
|
|
160
|
+
}
|
|
161
|
+
const abs = resolveToAbsolute(rel);
|
|
162
|
+
entry.currentHash = hashFile(abs);
|
|
163
|
+
entry.lastReconciledAt = timestamp ?? now();
|
|
164
|
+
if (kind) {
|
|
165
|
+
entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
recordEventHint,
|
|
170
|
+
reconcilePath,
|
|
171
|
+
reconcileKnownPaths,
|
|
172
|
+
getSessionEdits,
|
|
173
|
+
getFocusEdit,
|
|
174
|
+
hasSessionEdits,
|
|
175
|
+
forceEdit,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SessionFingerprintInput {
|
|
2
|
+
sessionId?: string | undefined;
|
|
3
|
+
branch: string;
|
|
4
|
+
worktree: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SessionBaselineState<Cursor> {
|
|
7
|
+
fingerprint: string | null;
|
|
8
|
+
cursor: Cursor | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildSessionFingerprint(input: SessionFingerprintInput): string;
|
|
11
|
+
export declare function syncSessionBaselineState<Cursor>(state: SessionBaselineState<Cursor>, input: SessionFingerprintInput, captureBaseline: () => Cursor | null): SessionBaselineState<Cursor>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function buildSessionFingerprint(
|
|
2
|
+
// implements REQ-opencode-kibi-briefing-v6
|
|
3
|
+
input) {
|
|
4
|
+
return [
|
|
5
|
+
input.sessionId?.trim() || "unknown",
|
|
6
|
+
input.branch,
|
|
7
|
+
input.worktree,
|
|
8
|
+
].join("\0");
|
|
9
|
+
}
|
|
10
|
+
export function syncSessionBaselineState(
|
|
11
|
+
// implements REQ-opencode-kibi-briefing-v6
|
|
12
|
+
state, input, captureBaseline) {
|
|
13
|
+
const fingerprint = buildSessionFingerprint(input);
|
|
14
|
+
if (state.fingerprint === fingerprint) {
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
fingerprint,
|
|
19
|
+
cursor: captureBaseline(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
|
|
3
3
|
* read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
|
|
4
|
-
* the edited file path
|
|
5
|
-
* (in file order) then static links as a fallback, preserving file order.
|
|
4
|
+
* the edited file path via implements relationships.
|
|
6
5
|
*
|
|
7
6
|
* Supports both YAML formats: top-level array and { symbols: [...] } object.
|
|
8
7
|
* This function is purely synchronous and makes no runtime KB queries.
|
|
@@ -1,179 +1,16 @@
|
|
|
1
1
|
// implements REQ-opencode-smart-enforcement-v1
|
|
2
|
-
import {
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { loadKbSyncPaths } from "./file-filter.js";
|
|
2
|
+
import { getFileLinkedTargetsByType } from "./file-entity-links.js";
|
|
5
3
|
/**
|
|
6
4
|
* Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
|
|
7
5
|
* read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
|
|
8
|
-
* the edited file path
|
|
9
|
-
* (in file order) then static links as a fallback, preserving file order.
|
|
6
|
+
* the edited file path via implements relationships.
|
|
10
7
|
*
|
|
11
8
|
* Supports both YAML formats: top-level array and { symbols: [...] } object.
|
|
12
9
|
* This function is purely synchronous and makes no runtime KB queries.
|
|
13
10
|
*/
|
|
14
11
|
// implements REQ-opencode-smart-enforcement-v1
|
|
15
12
|
export function getSourceLinkedRequirementIds(worktree, editedAbsolutePath) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!symbolsPathRaw)
|
|
20
|
-
return [];
|
|
21
|
-
const symbolsPath = path.isAbsolute(symbolsPathRaw)
|
|
22
|
-
? symbolsPathRaw
|
|
23
|
-
: path.join(worktree, symbolsPathRaw);
|
|
24
|
-
if (!existsSync(symbolsPath))
|
|
25
|
-
return [];
|
|
26
|
-
const content = readFileSync(symbolsPath, "utf8");
|
|
27
|
-
const symbols = parseSymbolsYaml(content);
|
|
28
|
-
const relEdited = path
|
|
29
|
-
.relative(worktree, editedAbsolutePath)
|
|
30
|
-
.split(path.sep)
|
|
31
|
-
.join("/");
|
|
32
|
-
const matchedRows = symbols.filter((s) => s.sourceFile === relEdited);
|
|
33
|
-
if (matchedRows.length === 0)
|
|
34
|
-
return [];
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
const orderedIds = [];
|
|
37
|
-
// First pass: collect implements relationships in file order
|
|
38
|
-
for (const row of matchedRows) {
|
|
39
|
-
for (const r of row.relationships ?? []) {
|
|
40
|
-
if (r.type === "implements") {
|
|
41
|
-
const id = r.target;
|
|
42
|
-
if (!seen.has(id)) {
|
|
43
|
-
seen.add(id);
|
|
44
|
-
orderedIds.push(id);
|
|
45
|
-
if (orderedIds.length >= 3)
|
|
46
|
-
return orderedIds.slice(0, 3);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// Second pass: fall back to static links, preserving file order
|
|
52
|
-
for (const row of matchedRows) {
|
|
53
|
-
for (const l of row.links ?? []) {
|
|
54
|
-
if (!seen.has(l)) {
|
|
55
|
-
seen.add(l);
|
|
56
|
-
orderedIds.push(l);
|
|
57
|
-
if (orderedIds.length >= 3)
|
|
58
|
-
return orderedIds.slice(0, 3);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return orderedIds.slice(0, 3);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return [];
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// ── Lightweight YAML parser (symbols.yaml subset) ────────────────────
|
|
69
|
-
//
|
|
70
|
-
// Handles:
|
|
71
|
-
// symbols:
|
|
72
|
-
// - id: SYM-xxx
|
|
73
|
-
// sourceFile: path/to/file
|
|
74
|
-
// links:
|
|
75
|
-
// - REQ-xxx
|
|
76
|
-
// relationships:
|
|
77
|
-
// - type: implements
|
|
78
|
-
// target: REQ-xxx
|
|
79
|
-
//
|
|
80
|
-
// And bare array format (no wrapping `symbols:` key):
|
|
81
|
-
// - id: SYM-xxx
|
|
82
|
-
// ...
|
|
83
|
-
function parseSymbolsYaml(content) {
|
|
84
|
-
const entries = [];
|
|
85
|
-
const lines = content.split("\n");
|
|
86
|
-
let current = null;
|
|
87
|
-
let section = "none";
|
|
88
|
-
let pendingRel = null;
|
|
89
|
-
function flushRel() {
|
|
90
|
-
if (pendingRel?.type && pendingRel.target && current?.relationships) {
|
|
91
|
-
current.relationships.push({
|
|
92
|
-
type: pendingRel.type,
|
|
93
|
-
target: pendingRel.target,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
pendingRel = null;
|
|
97
|
-
}
|
|
98
|
-
function flushEntry() {
|
|
99
|
-
flushRel();
|
|
100
|
-
if (current?.id && current?.sourceFile) {
|
|
101
|
-
entries.push(current);
|
|
102
|
-
}
|
|
103
|
-
current = null;
|
|
104
|
-
section = "none";
|
|
105
|
-
}
|
|
106
|
-
for (const raw of lines) {
|
|
107
|
-
if (raw.trim().startsWith("#"))
|
|
108
|
-
continue;
|
|
109
|
-
// New entry: " - id: ..."
|
|
110
|
-
const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
|
|
111
|
-
if (entryMatch) {
|
|
112
|
-
flushEntry();
|
|
113
|
-
const entryId = entryMatch[1];
|
|
114
|
-
if (entryId === undefined)
|
|
115
|
-
continue;
|
|
116
|
-
current = { id: entryId.trim(), links: [], relationships: [] };
|
|
117
|
-
section = "none";
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (!current)
|
|
121
|
-
continue;
|
|
122
|
-
// sourceFile
|
|
123
|
-
const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
|
|
124
|
-
if (srcMatch) {
|
|
125
|
-
const sourceFile = srcMatch[1];
|
|
126
|
-
if (sourceFile === undefined)
|
|
127
|
-
continue;
|
|
128
|
-
current.sourceFile = sourceFile.trim();
|
|
129
|
-
section = "none";
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
// links section header
|
|
133
|
-
if (/^\s+links:\s*$/.test(raw)) {
|
|
134
|
-
flushRel();
|
|
135
|
-
section = "links";
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
// relationships section header
|
|
139
|
-
if (/^\s+relationships:\s*$/.test(raw)) {
|
|
140
|
-
flushRel();
|
|
141
|
-
section = "relationships";
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
// Link item: " - REQ-xxx"
|
|
145
|
-
if (section === "links") {
|
|
146
|
-
const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
|
|
147
|
-
if (linkMatch) {
|
|
148
|
-
const linkId = linkMatch[1];
|
|
149
|
-
if (linkId !== undefined && current.links) {
|
|
150
|
-
current.links.push(linkId);
|
|
151
|
-
}
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
// Relationship type: " - type: implements"
|
|
156
|
-
if (section === "relationships") {
|
|
157
|
-
const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
|
|
158
|
-
if (relTypeMatch) {
|
|
159
|
-
flushRel();
|
|
160
|
-
const relationType = relTypeMatch[1];
|
|
161
|
-
if (relationType === undefined)
|
|
162
|
-
continue;
|
|
163
|
-
pendingRel = { type: relationType.trim() };
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
// Relationship target: " target: REQ-..."
|
|
167
|
-
const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
|
|
168
|
-
if (relTargetMatch && pendingRel) {
|
|
169
|
-
const target = relTargetMatch[1];
|
|
170
|
-
if (target === undefined)
|
|
171
|
-
continue;
|
|
172
|
-
pendingRel.target = target.trim();
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
flushEntry();
|
|
178
|
-
return entries;
|
|
13
|
+
// Delegate to the shared file-entity-links resolver with implements-only filter.
|
|
14
|
+
// implements relationships always target REQ- IDs, so no additional filtering needed.
|
|
15
|
+
return getFileLinkedTargetsByType(worktree, editedAbsolutePath, ["implements"]).slice(0, 3);
|
|
179
16
|
}
|
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
message: string;
|
|
5
|
-
duration?: number;
|
|
6
|
-
};
|
|
7
|
-
export type StartupNotifierClient = {
|
|
8
|
-
tui?: {
|
|
9
|
-
showToast?: (payload: {
|
|
10
|
-
body: {
|
|
11
|
-
title?: string;
|
|
12
|
-
message: string;
|
|
13
|
-
variant?: "info" | "success" | "warning" | "error";
|
|
14
|
-
duration?: number;
|
|
15
|
-
};
|
|
16
|
-
}) => void | Promise<void>;
|
|
17
|
-
toast?: (payload: ToastPayload) => void | Promise<void>;
|
|
18
|
-
};
|
|
1
|
+
import { type ToastCapableClient } from "./toast.js";
|
|
2
|
+
export type { ToastPayload } from "./toast.js";
|
|
3
|
+
export type StartupNotifierClient = ToastCapableClient & {
|
|
19
4
|
app: {
|
|
20
5
|
log: (payload: Record<string, unknown>) => Promise<void>;
|
|
21
6
|
};
|
package/dist/startup-notifier.js
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
return typeof client.tui?.showToast === "function";
|
|
3
|
-
}
|
|
4
|
-
function hasLegacyToast(client) {
|
|
5
|
-
return typeof client.tui?.toast === "function";
|
|
6
|
-
}
|
|
1
|
+
import { sendToast, } from "./toast.js";
|
|
7
2
|
// implements REQ-opencode-kibi-plugin-v1
|
|
8
3
|
export function notifyStartup(client, cfg) {
|
|
9
4
|
const message = "kibi-opencode started";
|
|
@@ -14,39 +9,50 @@ export function notifyStartup(client, cfg) {
|
|
|
14
9
|
duration: 4000,
|
|
15
10
|
};
|
|
16
11
|
if (!cfg.suppressToast) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.catch((
|
|
31
|
-
|
|
32
|
-
|
|
12
|
+
void sendToast(client, toastPayload).then((result) => {
|
|
13
|
+
const base = {
|
|
14
|
+
service: "kibi-opencode",
|
|
15
|
+
...(cfg.directory ? { directory: cfg.directory } : {}),
|
|
16
|
+
};
|
|
17
|
+
if (result.status === "delivered") {
|
|
18
|
+
void client.app.log({
|
|
19
|
+
body: {
|
|
20
|
+
...base,
|
|
21
|
+
level: "info",
|
|
22
|
+
message: "startup toast delivered",
|
|
23
|
+
transport: result.transport,
|
|
24
|
+
},
|
|
25
|
+
}).catch(() => {
|
|
26
|
+
// Advisory log failure stays silent
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else if (result.status === "unavailable") {
|
|
30
|
+
void client.app.log({
|
|
31
|
+
body: {
|
|
32
|
+
...base,
|
|
33
|
+
level: "info",
|
|
34
|
+
message: "startup toast unavailable",
|
|
35
|
+
reason: result.reason,
|
|
36
|
+
},
|
|
37
|
+
}).catch(() => {
|
|
38
|
+
// Advisory log failure stays silent
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else if (result.status === "failed") {
|
|
42
|
+
void client.app.log({
|
|
33
43
|
body: {
|
|
34
|
-
|
|
44
|
+
...base,
|
|
35
45
|
level: "warn",
|
|
36
|
-
message: "startup toast failed",
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
message: "startup toast delivery failed",
|
|
47
|
+
transport: result.transport,
|
|
48
|
+
reason: result.reason,
|
|
49
|
+
...(result.error ? { error: result.error } : {}),
|
|
39
50
|
},
|
|
40
|
-
})
|
|
41
|
-
|
|
51
|
+
}).catch(() => {
|
|
52
|
+
// Advisory log failure stays silent
|
|
42
53
|
});
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
else if (hasLegacyToast(client)) {
|
|
46
|
-
void Promise.resolve(client.tui.toast(toastPayload)).catch((err) => {
|
|
47
|
-
console.error("[kibi-opencode] startup toast failed:", err);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
50
56
|
}
|
|
51
57
|
void Promise.resolve(client.app.log({
|
|
52
58
|
body: {
|
|
@@ -57,6 +63,6 @@ export function notifyStartup(client, cfg) {
|
|
|
57
63
|
...(cfg.directory ? { directory: cfg.directory } : {}),
|
|
58
64
|
},
|
|
59
65
|
})).catch((err) => {
|
|
60
|
-
|
|
66
|
+
// Advisory log failure stays silent
|
|
61
67
|
});
|
|
62
68
|
}
|
package/dist/toast.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type ToastPayload = {
|
|
2
|
+
variant?: "info" | "success" | "warning" | "error";
|
|
3
|
+
title?: string;
|
|
4
|
+
message: string;
|
|
5
|
+
duration?: number;
|
|
6
|
+
};
|
|
7
|
+
export type SendToastResult = {
|
|
8
|
+
status: "delivered";
|
|
9
|
+
transport: "legacy" | "sdk";
|
|
10
|
+
} | {
|
|
11
|
+
status: "unavailable";
|
|
12
|
+
reason: "missing-capability";
|
|
13
|
+
} | {
|
|
14
|
+
status: "failed";
|
|
15
|
+
transport: "legacy" | "sdk";
|
|
16
|
+
reason: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
export type ToastCapableClient = {
|
|
20
|
+
tui?: {
|
|
21
|
+
/** Legacy direct TUI toast (works in plugin context) */
|
|
22
|
+
toast?: (payload: ToastPayload) => void | Promise<void>;
|
|
23
|
+
/** SDK toast - receives { body: ToastPayload } */
|
|
24
|
+
showToast?: (payload: {
|
|
25
|
+
body: ToastPayload;
|
|
26
|
+
}) => void | Promise<void>;
|
|
27
|
+
clearPrompt?: () => void | Promise<void>;
|
|
28
|
+
submitPrompt?: () => void | Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare function sendToast(client: ToastCapableClient, payload: ToastPayload): Promise<SendToastResult>;
|
package/dist/toast.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
2
|
+
export async function sendToast(client, payload) {
|
|
3
|
+
if (typeof client.tui?.toast === "function") {
|
|
4
|
+
try {
|
|
5
|
+
await client.tui.toast(payload);
|
|
6
|
+
return { status: "delivered", transport: "legacy" };
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
return {
|
|
10
|
+
status: "failed",
|
|
11
|
+
transport: "legacy",
|
|
12
|
+
reason: "rejected",
|
|
13
|
+
error: err instanceof Error ? err.message : String(err),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (typeof client.tui?.showToast === "function") {
|
|
18
|
+
try {
|
|
19
|
+
const result = client.tui.showToast({ body: payload });
|
|
20
|
+
if (result && typeof result.then === "function") {
|
|
21
|
+
const timeout = new Promise((_, reject) => {
|
|
22
|
+
setTimeout(() => reject(new Error("showToast timed out")), 3000);
|
|
23
|
+
});
|
|
24
|
+
await Promise.race([result, timeout]);
|
|
25
|
+
}
|
|
26
|
+
return { status: "delivered", transport: "sdk" };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
status: "failed",
|
|
31
|
+
transport: "sdk",
|
|
32
|
+
reason: err instanceof Error && err.message === "showToast timed out"
|
|
33
|
+
? "timed-out"
|
|
34
|
+
: "rejected",
|
|
35
|
+
error: err instanceof Error ? err.message : String(err),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { status: "unavailable", reason: "missing-capability" };
|
|
40
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { IdleBriefEnvelope } from "./idle-brief-store.js";
|
|
2
|
+
export type ToastPayload = {
|
|
3
|
+
variant?: "info" | "success" | "warning" | "error";
|
|
4
|
+
title?: string;
|
|
5
|
+
message: string;
|
|
6
|
+
duration?: number;
|
|
7
|
+
};
|
|
8
|
+
export type ToastCapableClient = {
|
|
9
|
+
tui?: {
|
|
10
|
+
showToast?: (payload: {
|
|
11
|
+
body: ToastPayload;
|
|
12
|
+
}) => void | Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type SharedBriefPolicy = {
|
|
16
|
+
briefs: {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
channels: {
|
|
19
|
+
tui: boolean;
|
|
20
|
+
vscode: boolean;
|
|
21
|
+
};
|
|
22
|
+
tui: {
|
|
23
|
+
toast: boolean;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type LocalBriefConfig = {
|
|
28
|
+
autoSubmit: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type DeliverResult = {
|
|
31
|
+
delivered: boolean;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Delivers a Kibi briefing to the TUI via toast notification.
|
|
35
|
+
*
|
|
36
|
+
* Uses the REAL OpenCode plugin API:
|
|
37
|
+
* - client.tui.showToast(payload) — primary (and only) delivery mechanism
|
|
38
|
+
*
|
|
39
|
+
* The toast contains a rich summary from the envelope and is displayed
|
|
40
|
+
* for 8 seconds so users can read the content.
|
|
41
|
+
*
|
|
42
|
+
* @param client - OpenCode client with optional TUI capabilities
|
|
43
|
+
* @param envelope - Idle brief envelope containing briefing content
|
|
44
|
+
* @param sharedPolicy - Shared brief policy from `.kb/config.json`
|
|
45
|
+
* @param localConfig - Local OpenCode config
|
|
46
|
+
*/
|
|
47
|
+
export declare function deliverBriefTui(client: ToastCapableClient, envelope: IdleBriefEnvelope, sharedPolicy: SharedBriefPolicy, _localConfig: LocalBriefConfig): Promise<DeliverResult>;
|