@zinn-dev/opencode 0.0.0-stage → 0.1.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/LICENSE +9 -0
- package/README.md +24 -2
- package/package.json +62 -3
- package/pkg/commands.ts +89 -0
- package/pkg/components.tsx +221 -0
- package/pkg/data.ts +349 -0
- package/pkg/dialogs.tsx +208 -0
- package/pkg/options.ts +39 -0
- package/pkg/rows.ts +109 -0
- package/pkg/scan.ts +267 -0
- package/pkg/session.ts +175 -0
- package/pkg/sidebar.tsx +543 -0
- package/pkg/theme.ts +51 -0
- package/pkg/tui.tsx +32 -0
- package/pkg/types.ts +66 -0
package/pkg/scan.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { Ctx, Mention, Scan, TaskSummary } from "./types.ts";
|
|
2
|
+
import { asString, isRecord, log } from "./data.ts";
|
|
3
|
+
|
|
4
|
+
const KEY_SHAPE = /\b[A-Z][A-Z0-9]{0,9}-\d{1,6}\b/g;
|
|
5
|
+
|
|
6
|
+
export function prefixOf(key: string): string {
|
|
7
|
+
const dash = key.indexOf("-");
|
|
8
|
+
return dash === -1 ? key : key.slice(0, dash);
|
|
9
|
+
}
|
|
10
|
+
export const SCAN_LOG_EVERY = 50;
|
|
11
|
+
export let scanCount = 0;
|
|
12
|
+
export const ZINN_INVOCATION =
|
|
13
|
+
/(?:^|[\s;&|("'`])(?:[^\s;&|("'`]*\/)?zinn(?:\.exe)?(?=$|[\s;&|)"'`])/;
|
|
14
|
+
export type CachedMessage = {
|
|
15
|
+
tokens: string[];
|
|
16
|
+
textParts: number;
|
|
17
|
+
streamingParts: number;
|
|
18
|
+
plan: Array<{
|
|
19
|
+
content?: unknown;
|
|
20
|
+
}> | null;
|
|
21
|
+
zinnCommands: number;
|
|
22
|
+
};
|
|
23
|
+
export type ScanCache = {
|
|
24
|
+
byID: Map<string, CachedMessage>;
|
|
25
|
+
computes: number;
|
|
26
|
+
};
|
|
27
|
+
export type ScanMessage = {
|
|
28
|
+
readonly id?: unknown;
|
|
29
|
+
readonly type: string;
|
|
30
|
+
readonly text?: unknown;
|
|
31
|
+
readonly time?: {
|
|
32
|
+
readonly completed?: unknown;
|
|
33
|
+
};
|
|
34
|
+
readonly content?: ReadonlyArray<unknown>;
|
|
35
|
+
};
|
|
36
|
+
export function isCacheableMessage(message: ScanMessage): boolean {
|
|
37
|
+
if (message.type === "user") {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return message.type === "assistant" && message.time?.completed !== undefined;
|
|
41
|
+
}
|
|
42
|
+
export function extractMessage(message: ScanMessage): CachedMessage {
|
|
43
|
+
const tokens: string[] = [];
|
|
44
|
+
let textParts = 0;
|
|
45
|
+
let streamingParts = 0;
|
|
46
|
+
let plan: Array<{
|
|
47
|
+
content?: unknown;
|
|
48
|
+
}> | null = null;
|
|
49
|
+
let zinnCommands = 0;
|
|
50
|
+
|
|
51
|
+
const take = (text: unknown) => {
|
|
52
|
+
if (typeof text !== "string") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
KEY_SHAPE.lastIndex = 0;
|
|
56
|
+
let match: RegExpExecArray | null;
|
|
57
|
+
while ((match = KEY_SHAPE.exec(text)) !== null) {
|
|
58
|
+
tokens.push(match[0]);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (message.type === "user") {
|
|
63
|
+
textParts += 1;
|
|
64
|
+
take(message.text);
|
|
65
|
+
return { tokens, textParts, streamingParts, plan, zinnCommands };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (message.type !== "assistant") {
|
|
69
|
+
return { tokens, textParts, streamingParts, plan, zinnCommands };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const streaming = message.time?.completed === undefined;
|
|
73
|
+
for (const raw of message.content ?? []) {
|
|
74
|
+
if (!isRecord(raw)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (raw["type"] === "text") {
|
|
79
|
+
textParts += 1;
|
|
80
|
+
if (streaming) {
|
|
81
|
+
streamingParts += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
take(raw["text"]);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (raw["type"] !== "tool") {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const state = isRecord(raw["state"]) ? raw["state"] : undefined;
|
|
92
|
+
const input = isRecord(state?.["input"]) ? state["input"] : undefined;
|
|
93
|
+
const command = input?.["command"];
|
|
94
|
+
|
|
95
|
+
if (typeof command === "string" && ZINN_INVOCATION.test(command)) {
|
|
96
|
+
zinnCommands += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const toolName = raw["name"] ?? raw["tool"];
|
|
100
|
+
if (
|
|
101
|
+
toolName === "todowrite" &&
|
|
102
|
+
state?.["status"] === "completed" &&
|
|
103
|
+
Array.isArray(input?.["todos"])
|
|
104
|
+
) {
|
|
105
|
+
plan = input!["todos"] as Array<{
|
|
106
|
+
content?: unknown;
|
|
107
|
+
}>;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { tokens, textParts, streamingParts, plan, zinnCommands };
|
|
111
|
+
}
|
|
112
|
+
export function scanSession(
|
|
113
|
+
ctx: Ctx,
|
|
114
|
+
sessionID: string,
|
|
115
|
+
map: Map<string, TaskSummary>,
|
|
116
|
+
cache?: ScanCache,
|
|
117
|
+
): Scan {
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
const order: string[] = [];
|
|
120
|
+
const counts = new Map<string, number>();
|
|
121
|
+
const lastSeen = new Map<string, number>();
|
|
122
|
+
const prefixes = new Set<string>();
|
|
123
|
+
let textParts = 0;
|
|
124
|
+
let streamingParts = 0;
|
|
125
|
+
let zinnCommands = 0;
|
|
126
|
+
let seq = 0;
|
|
127
|
+
let plan: Array<{
|
|
128
|
+
content?: unknown;
|
|
129
|
+
}> = [];
|
|
130
|
+
|
|
131
|
+
for (const existing of map.keys()) {
|
|
132
|
+
prefixes.add(prefixOf(existing));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const collect = (text: string) => {
|
|
136
|
+
KEY_SHAPE.lastIndex = 0;
|
|
137
|
+
let match: RegExpExecArray | null;
|
|
138
|
+
while ((match = KEY_SHAPE.exec(text)) !== null) {
|
|
139
|
+
const token = match[0];
|
|
140
|
+
if (!counts.has(token)) {
|
|
141
|
+
order.push(token);
|
|
142
|
+
}
|
|
143
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
144
|
+
lastSeen.set(token, seq++);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const messages = ctx.data.session.message.list(sessionID);
|
|
148
|
+
scanCount += 1;
|
|
149
|
+
const sampled = scanCount === 1 || scanCount % SCAN_LOG_EVERY === 0;
|
|
150
|
+
const facts: CachedMessage[] = [];
|
|
151
|
+
const ids: string[] = [];
|
|
152
|
+
let computes = 0;
|
|
153
|
+
|
|
154
|
+
for (const message of messages) {
|
|
155
|
+
const view = message as unknown as ScanMessage;
|
|
156
|
+
const id = asString(view.id);
|
|
157
|
+
if (id !== undefined) {
|
|
158
|
+
ids.push(id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hit = id !== undefined && cache !== undefined ? cache.byID.get(id) : undefined;
|
|
162
|
+
if (hit !== undefined) {
|
|
163
|
+
facts.push(hit);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const extracted = extractMessage(view);
|
|
168
|
+
computes += 1;
|
|
169
|
+
if (id !== undefined && cache !== undefined && isCacheableMessage(view)) {
|
|
170
|
+
cache.byID.set(id, extracted);
|
|
171
|
+
}
|
|
172
|
+
facts.push(extracted);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const factsOfMessage of facts) {
|
|
176
|
+
for (const token of factsOfMessage.tokens) {
|
|
177
|
+
if (!counts.has(token)) {
|
|
178
|
+
order.push(token);
|
|
179
|
+
}
|
|
180
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
181
|
+
lastSeen.set(token, seq++);
|
|
182
|
+
}
|
|
183
|
+
textParts += factsOfMessage.textParts;
|
|
184
|
+
streamingParts += factsOfMessage.streamingParts;
|
|
185
|
+
zinnCommands += factsOfMessage.zinnCommands;
|
|
186
|
+
if (factsOfMessage.plan !== null) {
|
|
187
|
+
plan = factsOfMessage.plan;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (cache !== undefined) {
|
|
192
|
+
const live = new Set(ids);
|
|
193
|
+
for (const key of [...cache.byID.keys()]) {
|
|
194
|
+
if (!live.has(key)) {
|
|
195
|
+
cache.byID.delete(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
cache.computes += computes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (sampled) {
|
|
202
|
+
log(ctx, "scan input", {
|
|
203
|
+
session: sessionID,
|
|
204
|
+
messages: messages.length,
|
|
205
|
+
computed: computes,
|
|
206
|
+
cached: messages.length - computes,
|
|
207
|
+
scan: scanCount,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let planKeys = 0;
|
|
212
|
+
for (const item of plan) {
|
|
213
|
+
const content = item?.content;
|
|
214
|
+
if (typeof content !== "string") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const before = order.length;
|
|
218
|
+
collect(content);
|
|
219
|
+
planKeys += order.length - before;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (sampled) {
|
|
223
|
+
log(ctx, "scan plan", {
|
|
224
|
+
session: sessionID,
|
|
225
|
+
items: plan.length,
|
|
226
|
+
keys: planKeys,
|
|
227
|
+
scan: scanCount,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const byRecency = (keys: string[]): string[] =>
|
|
232
|
+
[...keys].sort((a, b) => (lastSeen.get(b) ?? 0) - (lastSeen.get(a) ?? 0));
|
|
233
|
+
const known: Mention[] = [];
|
|
234
|
+
const unknown: Mention[] = [];
|
|
235
|
+
for (const key of byRecency(order)) {
|
|
236
|
+
const mention = { key, count: counts.get(key) ?? 1 };
|
|
237
|
+
if (map.has(key)) {
|
|
238
|
+
known.push(mention);
|
|
239
|
+
} else if (prefixes.has(prefixOf(key))) {
|
|
240
|
+
unknown.push(mention);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
known,
|
|
246
|
+
unknown,
|
|
247
|
+
tokens: order.length,
|
|
248
|
+
textParts,
|
|
249
|
+
streamingParts,
|
|
250
|
+
zinnCommands,
|
|
251
|
+
ms: Date.now() - started,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function newestIdleID(
|
|
256
|
+
messages: ReadonlyArray<{
|
|
257
|
+
id: string;
|
|
258
|
+
type: string;
|
|
259
|
+
}>,
|
|
260
|
+
): string | undefined {
|
|
261
|
+
const last = messages[messages.length - 1];
|
|
262
|
+
return last !== undefined && last.type === "idle" ? last.id : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function mentionCounts(mentions: readonly Mention[]): ReadonlyMap<string, number> {
|
|
266
|
+
return new Map(mentions.map((mention) => [mention.key, mention.count]));
|
|
267
|
+
}
|
package/pkg/session.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Ctx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
import { isRecord, runZinn } from "./data.ts";
|
|
4
|
+
|
|
5
|
+
// Bound concurrent CLI reads across visible tasks.
|
|
6
|
+
export const PROBE_CONCURRENCY = 8;
|
|
7
|
+
export let activityProbesSupported = true;
|
|
8
|
+
// Session and thread filters are separate reads because the CLI combines them with AND.
|
|
9
|
+
export async function probeSessionTask(
|
|
10
|
+
sessionID: string,
|
|
11
|
+
key: string,
|
|
12
|
+
): Promise<{
|
|
13
|
+
mine: boolean;
|
|
14
|
+
unsupported: boolean;
|
|
15
|
+
}> {
|
|
16
|
+
let unsupported = false;
|
|
17
|
+
const filters = [
|
|
18
|
+
{ flag: "--session", field: "actor_session_id" },
|
|
19
|
+
{ flag: "--thread", field: "actor_thread_id" },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
for (const { flag, field } of filters) {
|
|
23
|
+
const result = await runZinn([
|
|
24
|
+
"activity",
|
|
25
|
+
"list",
|
|
26
|
+
"--task",
|
|
27
|
+
key,
|
|
28
|
+
flag,
|
|
29
|
+
sessionID,
|
|
30
|
+
"--json",
|
|
31
|
+
"--limit",
|
|
32
|
+
"1",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
if (!result.ok) {
|
|
36
|
+
unsupported ||= result.code === "invalid_input";
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let data: unknown;
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(result.value);
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(data) && data.some((row) => isRecord(row) && row[field] === sessionID)) {
|
|
48
|
+
return { mine: true, unsupported: false };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { mine: false, unsupported };
|
|
53
|
+
}
|
|
54
|
+
// Child-session writes belong to the same chat family.
|
|
55
|
+
export function probeSessions(ctx: Ctx, sessionID: string): string[] {
|
|
56
|
+
const ids = new Set<string>();
|
|
57
|
+
if (sessionID !== "") {
|
|
58
|
+
ids.add(sessionID);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const root = ctx.data.session.root(sessionID);
|
|
63
|
+
if (typeof root === "string" && root !== "") {
|
|
64
|
+
ids.add(root);
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const family = ctx.data.session.family(sessionID);
|
|
70
|
+
if (Array.isArray(family)) {
|
|
71
|
+
for (const id of family) {
|
|
72
|
+
if (typeof id === "string" && id !== "") {
|
|
73
|
+
ids.add(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
79
|
+
return [...ids];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function probeAnySession(
|
|
83
|
+
sessions: readonly string[],
|
|
84
|
+
key: string,
|
|
85
|
+
): Promise<{
|
|
86
|
+
mine: boolean;
|
|
87
|
+
unsupported: boolean;
|
|
88
|
+
}> {
|
|
89
|
+
let unsupported = false;
|
|
90
|
+
|
|
91
|
+
for (const sid of sessions) {
|
|
92
|
+
const probe = await probeSessionTask(sid, key);
|
|
93
|
+
if (probe.unsupported) {
|
|
94
|
+
unsupported = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (probe.mine) {
|
|
98
|
+
return { mine: true, unsupported: false };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { mine: false, unsupported };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Superseded cycles stop scheduling reads already waiting for a lane.
|
|
105
|
+
export async function runPool<T>(
|
|
106
|
+
items: readonly T[],
|
|
107
|
+
limit: number,
|
|
108
|
+
worker: (item: T) => Promise<void>,
|
|
109
|
+
shouldStop?: () => boolean,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
let next = 0;
|
|
112
|
+
const lanes: Promise<void>[] = [];
|
|
113
|
+
const stopped = () => shouldStop !== undefined && shouldStop();
|
|
114
|
+
const drive = async () => {
|
|
115
|
+
while (next < items.length) {
|
|
116
|
+
if (stopped()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const item = items[next++]!;
|
|
120
|
+
await worker(item);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for (let i = 0; i < Math.min(limit, items.length); i++) {
|
|
124
|
+
lanes.push(drive());
|
|
125
|
+
}
|
|
126
|
+
await Promise.all(lanes);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sessionTab(ctx: Ctx, sessionID: string) {
|
|
130
|
+
const tabs: Ctx["ui"]["tabs"] | undefined = ctx.ui.tabs;
|
|
131
|
+
if (tabs === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof tabs.enabled !== "function" || typeof tabs.list !== "function") {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!tabs.enabled()) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const list = tabs.list();
|
|
144
|
+
if (!Array.isArray(list)) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return list.find((candidate) => candidate.sessionID === sessionID);
|
|
149
|
+
}
|
|
150
|
+
export function sessionWaiting(ctx: Ctx, sessionID: string): boolean {
|
|
151
|
+
const permissions = ctx.data.session.permission?.list(sessionID);
|
|
152
|
+
if ((permissions?.length ?? 0) > 0) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const forms = ctx.data.session.form?.list(sessionID);
|
|
157
|
+
return (forms?.length ?? 0) > 0;
|
|
158
|
+
}
|
|
159
|
+
export function sessionRunning(ctx: Ctx, sessionID: string): boolean {
|
|
160
|
+
const tab = sessionTab(ctx, sessionID);
|
|
161
|
+
if (tab !== undefined) {
|
|
162
|
+
return tab.busy;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (sessionWaiting(ctx, sessionID)) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const status: unknown = ctx.data.session.status?.(sessionID);
|
|
170
|
+
const type = isRecord(status) ? status["type"] : status;
|
|
171
|
+
return type === "running" || type === "busy";
|
|
172
|
+
}
|
|
173
|
+
export function disableActivityProbes(): void {
|
|
174
|
+
activityProbesSupported = false;
|
|
175
|
+
}
|