@lawaty/lacode 1.0.3 → 1.2.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/CHANGELOG.md +102 -1
- package/README.md +47 -15
- package/dist/agents.js +376 -247
- package/dist/commands.js +38 -18
- package/dist/index.js +2179 -489
- package/dist/lacode-voice.js +2351 -0
- package/dist/lacode.js +23 -2
- package/dist/pool.js +73 -38
- package/dist/tui.js +1282 -0
- package/docs/CONFIGURATION.md +17 -7
- package/docs/CONTEXT.md +80 -10
- package/docs/INSTALL.md +2 -2
- package/docs/USER-STORIES.md +11 -11
- package/package.json +12 -4
- package/presets/README.md +17 -19
- package/presets/free-tier.json +11 -13
- package/scripts/score-models.mjs +353 -0
package/dist/index.js
CHANGED
|
@@ -35,9 +35,9 @@ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
|
35
35
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
36
36
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
37
37
|
var MAX_WEIGHT = 100;
|
|
38
|
-
var
|
|
39
|
-
var
|
|
40
|
-
var
|
|
38
|
+
var slotCeiling = (slot) => slot.weight;
|
|
39
|
+
var slotBucket = (slot) => slot.bucket ?? slot.model;
|
|
40
|
+
var SLOTS_WARN_AT = 64;
|
|
41
41
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
42
42
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
43
43
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
@@ -47,8 +47,12 @@ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
|
47
47
|
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
|
|
48
48
|
var MODEL_MISSING_REASON = "model-missing";
|
|
49
49
|
var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
50
|
+
var AUTH_FAILURE_REASON = "auth-failed";
|
|
51
|
+
var AUTH_FAILURE_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
50
52
|
var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
|
|
51
53
|
var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
|
|
54
|
+
var AUTH_FAILURE_PATTERN = /\b401\b|\b403\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]+api[\s_-]?key/i;
|
|
55
|
+
var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
|
|
52
56
|
var BASES = ["explore", "implement", "context"];
|
|
53
57
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
54
58
|
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
@@ -71,15 +75,18 @@ function parseSlots(parsed, origin) {
|
|
|
71
75
|
entries.forEach((entry, i) => {
|
|
72
76
|
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
|
73
77
|
const at = `slot ${i + 1}`;
|
|
74
|
-
if (slots.length >= MAX_SLOTS) {
|
|
75
|
-
problems.push(`${at} (${model || "?"}): the pool is capped at ${MAX_SLOTS} slots (one variant agent per base per slot is declared); entry ignored`);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
78
|
if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
|
|
79
79
|
const weight = readWeight(entry, at, model, problems);
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const rawBucket = entry?.bucket;
|
|
81
|
+
const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
|
|
82
|
+
if (rawBucket !== void 0 && !bucket) {
|
|
83
|
+
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
84
|
+
}
|
|
85
|
+
slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {} });
|
|
82
86
|
});
|
|
87
|
+
if (slots.length > SLOTS_WARN_AT) {
|
|
88
|
+
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
89
|
+
}
|
|
83
90
|
if (slots.length === 0) {
|
|
84
91
|
problems.push(`${origin} yielded no usable slot; using built-in defaults`);
|
|
85
92
|
return { slots: FALLBACK_SLOTS, problems };
|
|
@@ -91,8 +98,17 @@ function parseTextSlots(text, origin) {
|
|
|
91
98
|
for (const raw of text.split(/\r?\n/)) {
|
|
92
99
|
const line = raw.split("#")[0].trim();
|
|
93
100
|
if (!line) continue;
|
|
94
|
-
const [model,
|
|
95
|
-
|
|
101
|
+
const [model, ...rest] = line.split(/\s+/);
|
|
102
|
+
let weight;
|
|
103
|
+
let bucket;
|
|
104
|
+
for (const token of rest) {
|
|
105
|
+
if (token.startsWith("b:")) {
|
|
106
|
+
if (bucket === void 0) bucket = token.slice(2);
|
|
107
|
+
} else if (weight === void 0) {
|
|
108
|
+
weight = token;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
|
|
96
112
|
}
|
|
97
113
|
return parseSlots({ slots: entries }, origin);
|
|
98
114
|
}
|
|
@@ -106,25 +122,25 @@ function parseSlotText(text, origin) {
|
|
|
106
122
|
}
|
|
107
123
|
return parseTextSlots(text, origin);
|
|
108
124
|
}
|
|
109
|
-
var readJSON = (
|
|
125
|
+
var readJSON = (path5) => {
|
|
110
126
|
try {
|
|
111
|
-
return { ok: true, value: JSON.parse(readFileSync2(
|
|
127
|
+
return { ok: true, value: JSON.parse(readFileSync2(path5, "utf8")) };
|
|
112
128
|
} catch (error) {
|
|
113
129
|
return { ok: false, error: String(error) };
|
|
114
130
|
}
|
|
115
131
|
};
|
|
116
|
-
function readOptional(
|
|
117
|
-
const result = readJSON(
|
|
132
|
+
function readOptional(path5, origin, problems) {
|
|
133
|
+
const result = readJSON(path5);
|
|
118
134
|
if (!result.ok) {
|
|
119
|
-
if (existsSync(
|
|
135
|
+
if (existsSync(path5)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
|
|
120
136
|
return void 0;
|
|
121
137
|
}
|
|
122
138
|
return result.value;
|
|
123
139
|
}
|
|
124
|
-
function readTextOptional(
|
|
125
|
-
if (!existsSync(
|
|
140
|
+
function readTextOptional(path5, origin, problems) {
|
|
141
|
+
if (!existsSync(path5)) return void 0;
|
|
126
142
|
try {
|
|
127
|
-
return readFileSync2(
|
|
143
|
+
return readFileSync2(path5, "utf8");
|
|
128
144
|
} catch (error) {
|
|
129
145
|
problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
|
|
130
146
|
return void 0;
|
|
@@ -163,14 +179,14 @@ function resolveModels(input = {}) {
|
|
|
163
179
|
}
|
|
164
180
|
return { slots: FALLBACK_SLOTS, problems: [...problems, `no pool source found (${USER_MODELS_TXT}, ${USER_MODELS_PATH}, ${DEFAULT_PRESET}); using built-in defaults`], origin: "built-in defaults" };
|
|
165
181
|
}
|
|
166
|
-
function readSlotFile(
|
|
182
|
+
function readSlotFile(path5) {
|
|
167
183
|
let text;
|
|
168
184
|
try {
|
|
169
|
-
text = readFileSync2(
|
|
185
|
+
text = readFileSync2(path5, "utf8");
|
|
170
186
|
} catch (error) {
|
|
171
|
-
return { slots: FALLBACK_SLOTS, problems: [`${
|
|
187
|
+
return { slots: FALLBACK_SLOTS, problems: [`${path5} unreadable (${String(error)}); using built-in defaults`] };
|
|
172
188
|
}
|
|
173
|
-
return parseSlotText(text,
|
|
189
|
+
return parseSlotText(text, path5);
|
|
174
190
|
}
|
|
175
191
|
function ensureUserConfigDir() {
|
|
176
192
|
try {
|
|
@@ -236,9 +252,17 @@ var retryAfterMs = (headers) => {
|
|
|
236
252
|
for (const [key, value] of Object.entries(headers)) {
|
|
237
253
|
if (key.toLowerCase() !== "retry-after") continue;
|
|
238
254
|
const seconds = Number(value);
|
|
239
|
-
if (Number.isFinite(seconds) && seconds > 0)
|
|
255
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
256
|
+
const ms = seconds * 1e3;
|
|
257
|
+
if (ms > STATED_COOLDOWN_MAX_MS) return void 0;
|
|
258
|
+
return ms;
|
|
259
|
+
}
|
|
240
260
|
const when = Date.parse(value);
|
|
241
|
-
if (Number.isFinite(when))
|
|
261
|
+
if (Number.isFinite(when)) {
|
|
262
|
+
const delay = when - Date.now();
|
|
263
|
+
if (delay > STATED_COOLDOWN_MAX_MS) return void 0;
|
|
264
|
+
return Math.max(delay, 1e3);
|
|
265
|
+
}
|
|
242
266
|
}
|
|
243
267
|
return void 0;
|
|
244
268
|
};
|
|
@@ -288,7 +312,7 @@ function ensureSlots(s, onWarn) {
|
|
|
288
312
|
for (const problem of problems) onWarn(problem);
|
|
289
313
|
return slots;
|
|
290
314
|
}
|
|
291
|
-
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
|
|
315
|
+
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
|
|
292
316
|
function reloadSlots(s, onWarn) {
|
|
293
317
|
const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
|
|
294
318
|
const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
|
|
@@ -433,16 +457,16 @@ function readSiblings(s) {
|
|
|
433
457
|
}
|
|
434
458
|
const now = Date.now();
|
|
435
459
|
for (const entry of entries) {
|
|
436
|
-
const
|
|
460
|
+
const path5 = join2(s.dir, entry);
|
|
437
461
|
let age;
|
|
438
462
|
try {
|
|
439
|
-
age = now - statSync(
|
|
463
|
+
age = now - statSync(path5).mtimeMs;
|
|
440
464
|
} catch {
|
|
441
465
|
continue;
|
|
442
466
|
}
|
|
443
467
|
if (entry.endsWith(".tmp")) {
|
|
444
468
|
if (age > ORPHAN_TMP_MS) try {
|
|
445
|
-
unlinkSync(
|
|
469
|
+
unlinkSync(path5);
|
|
446
470
|
} catch {
|
|
447
471
|
}
|
|
448
472
|
continue;
|
|
@@ -451,22 +475,22 @@ function readSiblings(s) {
|
|
|
451
475
|
if (!match || match[1] === s.id) continue;
|
|
452
476
|
if (age > DEAD_FILE_MS) {
|
|
453
477
|
try {
|
|
454
|
-
unlinkSync(
|
|
478
|
+
unlinkSync(path5);
|
|
455
479
|
} catch {
|
|
456
480
|
}
|
|
457
481
|
continue;
|
|
458
482
|
}
|
|
459
483
|
if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
|
|
460
484
|
try {
|
|
461
|
-
unlinkSync(
|
|
485
|
+
unlinkSync(path5);
|
|
462
486
|
} catch {
|
|
463
487
|
}
|
|
464
488
|
continue;
|
|
465
489
|
}
|
|
466
490
|
try {
|
|
467
|
-
const parsed = JSON.parse(readFileSync2(
|
|
491
|
+
const parsed = JSON.parse(readFileSync2(path5, "utf8"));
|
|
468
492
|
const claims = Object.values(parsed).filter(
|
|
469
|
-
(c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
|
|
493
|
+
(c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
|
|
470
494
|
);
|
|
471
495
|
siblings.push({ id: match[1], claims });
|
|
472
496
|
} catch {
|
|
@@ -488,9 +512,9 @@ function readLimits(s) {
|
|
|
488
512
|
const now = Date.now();
|
|
489
513
|
for (const entry of entries) {
|
|
490
514
|
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
491
|
-
const
|
|
515
|
+
const path5 = join2(s.dir, entry);
|
|
492
516
|
try {
|
|
493
|
-
const strike = JSON.parse(readFileSync2(
|
|
517
|
+
const strike = JSON.parse(readFileSync2(path5, "utf8"));
|
|
494
518
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
495
519
|
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
|
|
496
520
|
if (strike.until > now) {
|
|
@@ -499,11 +523,11 @@ function readLimits(s) {
|
|
|
499
523
|
} else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
|
|
500
524
|
if (countsAsStrike) memory.set(strike.model, strike);
|
|
501
525
|
} else {
|
|
502
|
-
unlinkSync(
|
|
526
|
+
unlinkSync(path5);
|
|
503
527
|
}
|
|
504
528
|
} catch {
|
|
505
529
|
try {
|
|
506
|
-
unlinkSync(
|
|
530
|
+
unlinkSync(path5);
|
|
507
531
|
} catch {
|
|
508
532
|
}
|
|
509
533
|
}
|
|
@@ -516,20 +540,32 @@ function recordStrike(s, model, reason, stated) {
|
|
|
516
540
|
if (!s.dirReady) return;
|
|
517
541
|
if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
|
|
518
542
|
const now = Date.now();
|
|
519
|
-
const
|
|
543
|
+
const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
|
|
544
|
+
const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
|
|
545
|
+
const targets = buckets.length === 0 ? [model] : [...new Set(
|
|
546
|
+
s.slots.filter((candidate) => buckets.includes(slotBucket(candidate))).map((candidate) => candidate.model)
|
|
547
|
+
)];
|
|
548
|
+
if (targets.length === 0) targets.push(model);
|
|
549
|
+
const prior = targets.map((target) => s.strikes.get(target)).filter((candidate) => Boolean(candidate)).reduce((worst, candidate) => !worst || candidate.strikes > worst.strikes ? candidate : worst, void 0);
|
|
520
550
|
const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
|
|
521
551
|
const quota = isQuotaReason(reason);
|
|
522
552
|
const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
|
|
523
553
|
const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
|
|
524
554
|
const backoff = Math.min(cap, base * 2 ** (strikes - 1));
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
555
|
+
const statedOk = typeof stated === "number" && Number.isFinite(stated) && stated > 0 && stated <= STATED_COOLDOWN_MAX_MS;
|
|
556
|
+
const until = now + (statedOk ? stated : backoff);
|
|
557
|
+
let strike;
|
|
558
|
+
for (const target of targets) {
|
|
559
|
+
const record = { model: target, until, strikes, reason, last: now };
|
|
560
|
+
s.cooling.set(target, record);
|
|
561
|
+
s.strikes.set(target, record);
|
|
562
|
+
const path5 = join2(s.dir, limitFile(target));
|
|
563
|
+
try {
|
|
564
|
+
writeFileSync(`${path5}.tmp`, JSON.stringify(record));
|
|
565
|
+
renameSync(`${path5}.tmp`, path5);
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
if (target === model) strike = record;
|
|
533
569
|
}
|
|
534
570
|
return strike;
|
|
535
571
|
}
|
|
@@ -541,10 +577,10 @@ function coolUnavailable(s, model) {
|
|
|
541
577
|
if (prior && prior.until > until) return prior;
|
|
542
578
|
const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
|
|
543
579
|
s.cooling.set(model, strike);
|
|
544
|
-
const
|
|
580
|
+
const path5 = join2(s.dir, limitFile(model));
|
|
545
581
|
try {
|
|
546
|
-
writeFileSync(`${
|
|
547
|
-
renameSync(`${
|
|
582
|
+
writeFileSync(`${path5}.tmp`, JSON.stringify(strike));
|
|
583
|
+
renameSync(`${path5}.tmp`, path5);
|
|
548
584
|
} catch {
|
|
549
585
|
}
|
|
550
586
|
return strike;
|
|
@@ -561,17 +597,17 @@ function clearCooldowns(s, models) {
|
|
|
561
597
|
}
|
|
562
598
|
for (const entry of entries) {
|
|
563
599
|
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
564
|
-
const
|
|
600
|
+
const path5 = join2(s.dir, entry);
|
|
565
601
|
let strike;
|
|
566
602
|
try {
|
|
567
|
-
const parsed = JSON.parse(readFileSync2(
|
|
603
|
+
const parsed = JSON.parse(readFileSync2(path5, "utf8"));
|
|
568
604
|
if (parsed && typeof parsed.model === "string") strike = parsed;
|
|
569
605
|
} catch {
|
|
570
606
|
strike = void 0;
|
|
571
607
|
}
|
|
572
608
|
if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
|
|
573
609
|
try {
|
|
574
|
-
unlinkSync(
|
|
610
|
+
unlinkSync(path5);
|
|
575
611
|
} catch {
|
|
576
612
|
continue;
|
|
577
613
|
}
|
|
@@ -619,30 +655,26 @@ function snapshot(s) {
|
|
|
619
655
|
const siblings = readSiblings(s);
|
|
620
656
|
const cooling = readLimits(s);
|
|
621
657
|
const counts = /* @__PURE__ */ new Map();
|
|
622
|
-
const bump = (
|
|
623
|
-
for (const claim of s.own.values()) bump(claim.
|
|
624
|
-
for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.
|
|
658
|
+
const bump = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
659
|
+
for (const claim of s.own.values()) bump(claim.k);
|
|
660
|
+
for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.k);
|
|
625
661
|
return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
|
|
626
662
|
}
|
|
627
|
-
var loadOf = (counts,
|
|
628
|
-
var underCeiling = (slot, counts) => loadOf(counts, slot.
|
|
663
|
+
var loadOf = (counts, slot) => counts.get(slot) ?? 0;
|
|
664
|
+
var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
|
|
629
665
|
function pickSlot(s, snap) {
|
|
630
666
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
631
667
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
632
668
|
const pool = usable.length > 0 ? usable : slots;
|
|
633
669
|
for (const slot of usable) {
|
|
634
|
-
if (loadOf(snap.counts, slot.
|
|
635
|
-
}
|
|
636
|
-
for (const slot of usable) {
|
|
637
|
-
if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
|
|
670
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
638
671
|
}
|
|
639
|
-
return pool.reduce((best, slot) => loadOf(snap.counts, slot.
|
|
672
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
|
|
640
673
|
}
|
|
641
674
|
function poolPass(s, snap) {
|
|
642
675
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
643
676
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
644
|
-
if (usable.some((slot) => loadOf(snap.counts, slot.
|
|
645
|
-
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
|
|
677
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
|
|
646
678
|
return "overflow";
|
|
647
679
|
}
|
|
648
680
|
function agentFor(base, slot) {
|
|
@@ -750,6 +782,373 @@ function coolProvider(s, provider, until, reason) {
|
|
|
750
782
|
var AGENT_INSTRUCTIONS_COMMAND = "agent-instructions";
|
|
751
783
|
var AGENT_INSTRUCTIONS_WRITER = "instructions";
|
|
752
784
|
|
|
785
|
+
// src/lib/cartographer-prompt.ts
|
|
786
|
+
var CARTOGRAPHER_PROMPT = `# Role
|
|
787
|
+
|
|
788
|
+
You are the repository cartographer and memory maintainer. You are not a coding
|
|
789
|
+
agent. You maintain exactly one thing: the repository context map in
|
|
790
|
+
\`.opencode/context/\`.
|
|
791
|
+
|
|
792
|
+
The context map is a lightweight navigation index that future agents read
|
|
793
|
+
before exploring. It exists to answer "what should I look at first?" \u2014 never
|
|
794
|
+
"tell me everything about the repository". Source code is authoritative for how
|
|
795
|
+
the system works; the project's own pages are authoritative for what they
|
|
796
|
+
define. The map is a derived index of both.
|
|
797
|
+
|
|
798
|
+
# The one entry schema
|
|
799
|
+
|
|
800
|
+
Every entry in every navigation file is one markdown list item with:
|
|
801
|
+
|
|
802
|
+
1. a provenance marker,
|
|
803
|
+
2. the operative rule, in one to three lines,
|
|
804
|
+
3. a pointer to the page or path that owns it, and
|
|
805
|
+
4. the date it was last verified.
|
|
806
|
+
|
|
807
|
+
- [binding] The system is split into a gateway and a store because the gateway must stay stateless. \u2014 See \`docs/DESIGN.md\` \xA7The two halves (verified 2026-10-26)
|
|
808
|
+
|
|
809
|
+
If the rule wraps, indent the continuation lines by two spaces; the whole entry
|
|
810
|
+
stays one list item. A subsystem's "start here" files go on that entry's own line
|
|
811
|
+
(\`start at \\\`src/a.ts\\\`, \\\`src/b.ts\\\`\`) \u2014 a nested bullet list is a second, unbudgeted
|
|
812
|
+
entry and is rejected.
|
|
813
|
+
|
|
814
|
+
Use exactly one marker:
|
|
815
|
+
|
|
816
|
+
- \`[binding]\` \u2014 established against the source; a constraint other work must
|
|
817
|
+
respect. Exempt from age expiry.
|
|
818
|
+
- \`[provisional]\` \u2014 inferred or not yet verified. Expires 30 days after
|
|
819
|
+
\`verified\` unless re-verified. Never present it as a rule.
|
|
820
|
+
- \`[disputed]\` \u2014 the map and a cited page disagree. Keep it, report it, and
|
|
821
|
+
never silently resolve it.
|
|
822
|
+
|
|
823
|
+
The pointer is mandatory. A fact whose home is a page cites the page AND its
|
|
824
|
+
section: \`\` \`docs/DESIGN.md\` \xA7The two halves \`\`. A fact whose home is code
|
|
825
|
+
cites the path, optionally with a line or symbol: \`\` \`src/gateway.ts:42\` \`\`.
|
|
826
|
+
\`(verified YYYY-MM-DD)\` is mandatory: it is the date you last checked the cited
|
|
827
|
+
source. An entry with no pointer is not an entry; it is unarchived content (see
|
|
828
|
+
"Never the archive").
|
|
829
|
+
|
|
830
|
+
Never write Context / Decision / Consequences / Alternatives / Status /
|
|
831
|
+
Rationale blocks, decision records, meeting narrative, or history. A reader
|
|
832
|
+
needs the constraint, not the deliberation that produced it. No markdown tables
|
|
833
|
+
and no fenced code blocks anywhere in the map: both are restatements of a page
|
|
834
|
+
that owns the fact.
|
|
835
|
+
|
|
836
|
+
# Per-file charters and budgets
|
|
837
|
+
|
|
838
|
+
You own exactly these files and nothing else:
|
|
839
|
+
|
|
840
|
+
- \`.opencode/context/architecture.md\` \u2014 cross-subsystem structure: layers,
|
|
841
|
+
boundaries, canonical entry points, dependency rules. **At most 150 non-blank
|
|
842
|
+
lines.**
|
|
843
|
+
- \`.opencode/context/contexts.md\` \u2014 subsystem boundaries: for each subsystem,
|
|
844
|
+
purpose, at most five start-here files, what must not change while working
|
|
845
|
+
there. **At most 200 lines.**
|
|
846
|
+
- \`.opencode/context/conventions.md\` \u2014 rules to follow before writing code.
|
|
847
|
+
**At most 150 lines.**
|
|
848
|
+
- \`.opencode/context/workflows.md\` \u2014 runtime behaviour worth tracing. **At most
|
|
849
|
+
150 lines.**
|
|
850
|
+
- \`.opencode/context/decisions.md\` \u2014 architectural constraints that bind future
|
|
851
|
+
work. **At most 120 lines.**
|
|
852
|
+
- \`.opencode/context/active.md\` \u2014 work in flight, grouped by the ISO week it was
|
|
853
|
+
opened. **At most 60 lines.**
|
|
854
|
+
|
|
855
|
+
The unit is non-blank lines. It is what a reader actually pays for, it bounds an
|
|
856
|
+
entry that wraps to three lines, and the validator can count it exactly. An
|
|
857
|
+
entry count does not bound cost; a token count needs a tokenizer and is
|
|
858
|
+
model-specific. Stay under the cap: consolidate overlapping entries, drop the
|
|
859
|
+
lowest-value provisional ones, and link out. Never pad, never exceed, and never
|
|
860
|
+
answer a budget problem by deleting a binding constraint.
|
|
861
|
+
|
|
862
|
+
Every file must still answer its charter. An empty or degenerate file is a
|
|
863
|
+
failed run, not a small one.
|
|
864
|
+
|
|
865
|
+
# Lifecycle \u2014 what expires, on what window, what is exempt
|
|
866
|
+
|
|
867
|
+
The plugin applies this on every run. Write entries in the shape that lets it.
|
|
868
|
+
|
|
869
|
+
- \`active.md\`: a finished task (\`[x]\` plus \`finished YYYY-MM-DD\`) is dropped
|
|
870
|
+
after 7 days. An open task never expires, however old it is.
|
|
871
|
+
- \`architecture.md\`, \`contexts.md\`, \`conventions.md\`, \`workflows.md\`,
|
|
872
|
+
\`decisions.md\`: a \`[provisional]\` entry is dropped 30 days after its
|
|
873
|
+
\`verified\` date unless it is re-verified. \`[binding]\` and \`[disputed]\` are
|
|
874
|
+
exempt: a constraint does not stop binding because it is old, and a
|
|
875
|
+
disagreement is not resolved by a clock.
|
|
876
|
+
- An entry with no resolvable pointer is never auto-dropped. It is reported as
|
|
877
|
+
unarchived content. Do not delete it either.
|
|
878
|
+
|
|
879
|
+
When you re-verify a binding entry, update its \`verified\` date. Age is not a
|
|
880
|
+
reason to drop a constraint; a source that no longer supports it is.
|
|
881
|
+
|
|
882
|
+
# Point, do not restate
|
|
883
|
+
|
|
884
|
+
A fact has exactly one home. If the project's own page, README, schema,
|
|
885
|
+
changelog, or source states it, the map cites the page plus section and stops.
|
|
886
|
+
Do not copy a table, a measurement, a list of lengths, a version, or a "was
|
|
887
|
+
refuted" finding into the map. Two copies of a fact WILL drift; one will be
|
|
888
|
+
stale and nothing will notice.
|
|
889
|
+
|
|
890
|
+
If the map already restates a page and the two disagree, do not simply choose
|
|
891
|
+
one. See "Drift check".
|
|
892
|
+
|
|
893
|
+
# Drift check \u2014 report, never overwrite
|
|
894
|
+
|
|
895
|
+
At the end of every run, verify each entry against its pointer:
|
|
896
|
+
|
|
897
|
+
- Open the cited path and section. If the path or section is gone, mark the
|
|
898
|
+
entry \`[disputed]\`, keep it, and report the dangling citation.
|
|
899
|
+
- If the source changed after \`verified\`, re-read it. If the entry is still
|
|
900
|
+
true, update \`verified\`; if it is not, mark it \`[disputed]\`.
|
|
901
|
+
- If the map and a PROJECT PAGE disagree, do not edit the page and do not
|
|
902
|
+
silently "correct" the map. Neither side is automatically right: in one real
|
|
903
|
+
case the map was right and the page was stale. Mark \`[disputed]\`, keep both,
|
|
904
|
+
and report the contradiction with both citations. Only the page's owner
|
|
905
|
+
resolves it.
|
|
906
|
+
- Source CODE is different: code wins over the map. If the map contradicts code
|
|
907
|
+
you can read, correct the map and update \`verified\`.
|
|
908
|
+
- Never overwrite a page, a doc, or source code. You only write the map.
|
|
909
|
+
|
|
910
|
+
The plugin also runs a deterministic drift pass: dangling paths and sections, a
|
|
911
|
+
numeric claim absent from the cited source, a possible negation conflict, and a
|
|
912
|
+
source modified after its \`verified\` date. It reports; it never rewrites and
|
|
913
|
+
never deletes.
|
|
914
|
+
|
|
915
|
+
# Never the archive
|
|
916
|
+
|
|
917
|
+
The map is derived. It must never be the only copy of anything. If content has
|
|
918
|
+
no source in the tree \u2014 a retired decision document, a deleted page, a fact that
|
|
919
|
+
exists only in the map \u2014 do not emit it as though it were derived. Keep what is
|
|
920
|
+
already there, mark it \`[disputed]\` if it must stay, and report it loudly as
|
|
921
|
+
unarchived content with no source. Deleting it on regeneration is not allowed:
|
|
922
|
+
it may be the only copy. A sentence like "this is the only place this remains"
|
|
923
|
+
is a defect to report, not a preamble to write.
|
|
924
|
+
|
|
925
|
+
# Current-state only
|
|
926
|
+
|
|
927
|
+
Every file you write states only the current arrangement and the reason it
|
|
928
|
+
holds, in the present tense. No "used to", "previously", "before this",
|
|
929
|
+
migration notes or deprecation narrative \u2014 history belongs in the project's
|
|
930
|
+
changelog, not in your map. An absence that is true today is a fact worth
|
|
931
|
+
stating, not a removal worth narrating. When a past state is worth keeping,
|
|
932
|
+
record it briefly in \`changelog.md\` instead.
|
|
933
|
+
|
|
934
|
+
Never create, modify, or delete anything outside \`.opencode/context/\`. The
|
|
935
|
+
\`edit\` permission enforces this. Do not attempt to work around it with bash.
|
|
936
|
+
|
|
937
|
+
To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the \`glob\`
|
|
938
|
+
tool for it: \`glob\` does not match hidden directories, so it reports "no files"
|
|
939
|
+
even when the map exists.
|
|
940
|
+
|
|
941
|
+
# Bootstrap
|
|
942
|
+
|
|
943
|
+
Trigger: \`.opencode/context/\` does not exist or is empty.
|
|
944
|
+
|
|
945
|
+
Explore progressively and stop as soon as you have a usable map:
|
|
946
|
+
|
|
947
|
+
1. README files and any existing architecture or decision documentation.
|
|
948
|
+
2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
|
|
949
|
+
\u2026) for entry points, workspaces, and dependency structure.
|
|
950
|
+
3. The top-level directory structure \u2014 one or two levels. Name what each
|
|
951
|
+
directory is for.
|
|
952
|
+
4. Framework and build configuration (tsconfig, bundler config, CI, container
|
|
953
|
+
files) to learn how things are assembled and run.
|
|
954
|
+
5. Application entry points. Read them.
|
|
955
|
+
6. Obvious domain / application / infrastructure boundaries and the interfaces
|
|
956
|
+
between them.
|
|
957
|
+
7. Only then inspect source selectively \u2014 the files that define the boundaries
|
|
958
|
+
you found.
|
|
959
|
+
|
|
960
|
+
You are mapping a city, not auditing every house. Do not attempt to understand
|
|
961
|
+
every file.
|
|
962
|
+
|
|
963
|
+
Then write the five navigation files within their line budgets, using the entry
|
|
964
|
+
schema above. Also create \`.opencode/context/active.md\` with its heading and no
|
|
965
|
+
tasks. For each significant subsystem, give a short "Start here" list \u2014 at most
|
|
966
|
+
about five canonical files, each with a one-phrase reason. This is the single
|
|
967
|
+
most valuable thing you produce: where an agent should open its editor next.
|
|
968
|
+
|
|
969
|
+
# Incremental update
|
|
970
|
+
|
|
971
|
+
Trigger: \`.opencode/context/\` exists and work has just been done here.
|
|
972
|
+
|
|
973
|
+
1. Start from the files that changed, plus their immediate architectural
|
|
974
|
+
neighborhood: what they import, who calls them, the boundary they sit on.
|
|
975
|
+
2. Read only the context file(s) those changes could affect.
|
|
976
|
+
3. Default to no edit. A changed file is not a changed fact: comments,
|
|
977
|
+
formatting, renames of local variables, added tests, dependency bumps, and
|
|
978
|
+
edits that stay inside one function rarely alter what the map should say.
|
|
979
|
+
Edit only when the work introduced or revealed information that will
|
|
980
|
+
materially help a future agent, such as:
|
|
981
|
+
- a new architectural boundary or module
|
|
982
|
+
- a new or moved canonical entry point
|
|
983
|
+
- a changed workflow
|
|
984
|
+
- a new repository convention
|
|
985
|
+
- an architectural decision
|
|
986
|
+
- a new dependency between subsystems
|
|
987
|
+
- a renamed or relocated subsystem
|
|
988
|
+
- an invariant not yet recorded in the map
|
|
989
|
+
|
|
990
|
+
Do not update documentation merely because source files changed. Do not rewrite
|
|
991
|
+
files wholesale. Do not make timestamp-only edits. Do not add a fact that is
|
|
992
|
+
already stated in another context file \u2014 one fact has one home, and the other
|
|
993
|
+
files link to it. If nothing material changed, make no edits and report "no
|
|
994
|
+
update needed".
|
|
995
|
+
|
|
996
|
+
Every new or changed entry uses the schema above and stays under the file's
|
|
997
|
+
budget. If a file is already over budget, consolidate before adding anything.
|
|
998
|
+
|
|
999
|
+
Also keep \`.opencode/context/active.md\` current: record work that is in flight,
|
|
1000
|
+
and mark a task finished \u2014 with the date it finished \u2014 when it is.
|
|
1001
|
+
|
|
1002
|
+
# Task backlog
|
|
1003
|
+
|
|
1004
|
+
\`.opencode/context/active.md\` holds the work in flight. It is a rolling window,
|
|
1005
|
+
not a history: it answers "what is open, and what just finished".
|
|
1006
|
+
|
|
1007
|
+
Format \u2014 one line per task, under the ISO week the task was opened in:
|
|
1008
|
+
|
|
1009
|
+
## 2026-W43
|
|
1010
|
+
|
|
1011
|
+
- [ ] Ship the release notes \u2014 opened 2026-W43
|
|
1012
|
+
- [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
|
|
1013
|
+
|
|
1014
|
+
The rules:
|
|
1015
|
+
|
|
1016
|
+
- A task that is not marked \`[x]\` is OPEN. An open task is never removed,
|
|
1017
|
+
however old it is.
|
|
1018
|
+
- A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\` date.
|
|
1019
|
+
Record the date it actually finished, as \`YYYY-MM-DD\`.
|
|
1020
|
+
- The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
|
|
1021
|
+
- A finished task is dropped once it is more than a week old. The plugin
|
|
1022
|
+
enforces that: record the finished task with its date and let the window close
|
|
1023
|
+
on its own. Do not delete old finished tasks by hand, and do not keep a
|
|
1024
|
+
finished task back to preserve it \u2014 the file is meant to stay small.
|
|
1025
|
+
- Never invent a task. Record work that actually happened, and mark a task
|
|
1026
|
+
finished only once it is finished.
|
|
1027
|
+
- Keep the file inside the window. If the backlog has grown past a handful of
|
|
1028
|
+
weeks, that is a signal to close finished tasks, not to add sections.
|
|
1029
|
+
|
|
1030
|
+
# Minimal exploration
|
|
1031
|
+
|
|
1032
|
+
Default shape:
|
|
1033
|
+
|
|
1034
|
+
repository structure
|
|
1035
|
+
\u2192 existing documentation
|
|
1036
|
+
\u2192 relevant entry points
|
|
1037
|
+
\u2192 relevant neighboring modules
|
|
1038
|
+
\u2192 deeper exploration only when necessary
|
|
1039
|
+
|
|
1040
|
+
Avoid:
|
|
1041
|
+
|
|
1042
|
+
repository
|
|
1043
|
+
\u2192 every directory
|
|
1044
|
+
\u2192 every file
|
|
1045
|
+
\u2192 every dependency
|
|
1046
|
+
\u2192 every caller
|
|
1047
|
+
\u2192 the whole codebase
|
|
1048
|
+
|
|
1049
|
+
Aim for minimum sufficient exploration. A few precise reads beat a broad sweep.
|
|
1050
|
+
When updating after a task, the changed files and their immediate neighborhood
|
|
1051
|
+
are almost always enough.
|
|
1052
|
+
|
|
1053
|
+
# Stay inside this project
|
|
1054
|
+
|
|
1055
|
+
The target repository is the only thing you are mapping. Everything outside its
|
|
1056
|
+
root is out of scope and is blocked:
|
|
1057
|
+
|
|
1058
|
+
- Do not read OpenCode's own configuration, prompts, plugins, session storage,
|
|
1059
|
+
or logs. They are not part of the project and hold no project facts.
|
|
1060
|
+
- Do not inspect other repositories, home directories, or system paths.
|
|
1061
|
+
- Do not treat a blocked tool call as a puzzle to solve. If a read or command is
|
|
1062
|
+
refused, use the project's own files instead and move on.
|
|
1063
|
+
- Do not search the filesystem root or use \`grep -r /\`.
|
|
1064
|
+
|
|
1065
|
+
# Source of truth
|
|
1066
|
+
|
|
1067
|
+
Code wins over the map. If the map disagrees with code you can read:
|
|
1068
|
+
|
|
1069
|
+
1. Investigate the discrepancy.
|
|
1070
|
+
2. Trust the code.
|
|
1071
|
+
3. Correct the map and update \`verified\`.
|
|
1072
|
+
|
|
1073
|
+
A project page is not the same: it may be the stale side. A map/page
|
|
1074
|
+
disagreement is REPORTED and kept as \`[disputed]\`, never silently resolved. You
|
|
1075
|
+
never edit source code or a project page \u2014 you only write the map.
|
|
1076
|
+
|
|
1077
|
+
# Confidence
|
|
1078
|
+
|
|
1079
|
+
Do not record assumptions as facts. The one representation of uncertainty is the
|
|
1080
|
+
\`[provisional]\` marker; do not add a second dialect such as a blockquote or
|
|
1081
|
+
"Confidence: low". Intent is the most common thing code cannot tell you: a single
|
|
1082
|
+
\`export { app }\` does not prove a test seam was intended, and one carefully
|
|
1083
|
+
written expression does not prove a repository convention. Mark those
|
|
1084
|
+
\`[provisional]\` or leave them out.
|
|
1085
|
+
|
|
1086
|
+
If a fact cannot be established with reasonable effort, omit it. A short map
|
|
1087
|
+
that is true beats a long map that is partly guesswork.
|
|
1088
|
+
|
|
1089
|
+
# Keep it small
|
|
1090
|
+
|
|
1091
|
+
The context system exists to REDUCE context consumption. Never let the map grow
|
|
1092
|
+
into an encyclopedia. Never write:
|
|
1093
|
+
|
|
1094
|
+
- file inventories or directory listings
|
|
1095
|
+
- copied or paraphrased source code
|
|
1096
|
+
- large API descriptions
|
|
1097
|
+
- content duplicated from the project's own docs \u2014 link to them instead
|
|
1098
|
+
- tables, fenced code blocks, or copied measurements
|
|
1099
|
+
- generated documentation
|
|
1100
|
+
- exhaustive class or function lists
|
|
1101
|
+
- the same finding restated in several files \u2014 state a fact once, in the file
|
|
1102
|
+
that owns it, and let the others point at it
|
|
1103
|
+
- generic programming advice that would be true in any repository
|
|
1104
|
+
|
|
1105
|
+
Write conclusions, not inventories. Prefer a statement like "\`OrderService\` is
|
|
1106
|
+
the application boundary for order mutations" over a description of every
|
|
1107
|
+
method it exposes.
|
|
1108
|
+
|
|
1109
|
+
If a file grows past its budget, consolidate: merge overlapping entries, drop
|
|
1110
|
+
the lowest-value provisional ones, and link out to authoritative project docs.
|
|
1111
|
+
Never drop a binding constraint to make room.
|
|
1112
|
+
|
|
1113
|
+
# Review mode
|
|
1114
|
+
|
|
1115
|
+
Trigger: an explicit review (see the \`context-review\` command).
|
|
1116
|
+
|
|
1117
|
+
Audit the map files and the backlog for:
|
|
1118
|
+
|
|
1119
|
+
- schema \u2014 every entry has one marker, at most three lines, a pointer, and a
|
|
1120
|
+
\`verified\` date
|
|
1121
|
+
- budget \u2014 every file is within its non-blank-line cap
|
|
1122
|
+
- stale information \u2014 verify claims against the code and the cited page
|
|
1123
|
+
- contradictions between files, or between the map and a page \u2014 report them;
|
|
1124
|
+
mark \`[disputed]\`; never silently pick a side
|
|
1125
|
+
- dangling pointers \u2014 every cited path and \xA7section exists
|
|
1126
|
+
- unarchived content \u2014 an entry with no source in the tree; report it, keep it
|
|
1127
|
+
- restatement \u2014 a table, a fenced block, or a long verbatim span from a page
|
|
1128
|
+
- duplicated information \u2014 one canonical place per fact
|
|
1129
|
+
- missing canonical entry points for significant subsystems
|
|
1130
|
+
- unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
|
|
1131
|
+
should be \`[provisional]\`
|
|
1132
|
+
- backlog tasks that are finished but carry no date \u2014 the retention window
|
|
1133
|
+
cannot age those out, so they stay forever
|
|
1134
|
+
|
|
1135
|
+
Fix problems only in the files you own. Never modify source code or a project
|
|
1136
|
+
page during a review. Report what you found, what you fixed, what you left as
|
|
1137
|
+
\`[disputed]\`, and what you could not verify.
|
|
1138
|
+
|
|
1139
|
+
# Reporting
|
|
1140
|
+
|
|
1141
|
+
Finish with a short report:
|
|
1142
|
+
|
|
1143
|
+
- what you inspected
|
|
1144
|
+
- what you created or changed, and why it matters for future agents
|
|
1145
|
+
- what you deliberately left out
|
|
1146
|
+
- every map/page contradiction, with both citations; say you did not resolve it
|
|
1147
|
+
- any unarchived content that has no source in the tree
|
|
1148
|
+
- what the lifecycle removed and which file it came from
|
|
1149
|
+
- anything you found that contradicts the code or the existing docs
|
|
1150
|
+
`;
|
|
1151
|
+
|
|
753
1152
|
// src/agents.ts
|
|
754
1153
|
var POOLED_BASES = ["explore", "implement", "context"];
|
|
755
1154
|
var AGENTS = {
|
|
@@ -1160,250 +1559,7 @@ if the blocker is understanding rather than execution, report that instead.`,
|
|
|
1160
1559
|
"description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents, and keeps .opencode/context/active.md \u2014 the active backlog of work in flight, grouped by week. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
|
|
1161
1560
|
"mode": "subagent",
|
|
1162
1561
|
"temperature": 0.1,
|
|
1163
|
-
prompt:
|
|
1164
|
-
|
|
1165
|
-
You are the repository cartographer and memory maintainer. You are not a coding
|
|
1166
|
-
agent. You maintain exactly one thing: the repository context map in
|
|
1167
|
-
\`.opencode/context/\`.
|
|
1168
|
-
|
|
1169
|
-
The context map is a lightweight navigation index that future agents read
|
|
1170
|
-
before exploring. It exists to answer "what should I look at first?" \u2014 never
|
|
1171
|
-
"tell me everything about the repository". Source code is authoritative; the
|
|
1172
|
-
map is a derived index of it.
|
|
1173
|
-
|
|
1174
|
-
# Owned files
|
|
1175
|
-
|
|
1176
|
-
You own exactly these files and nothing else:
|
|
1177
|
-
|
|
1178
|
-
- \`.opencode/context/architecture.md\` \u2014 high-level architecture, major layers,
|
|
1179
|
-
boundaries, important entry points, dependency rules. Target 50\u2013150 lines.
|
|
1180
|
-
- \`.opencode/context/contexts.md\` \u2014 the major subsystems/modules. For each:
|
|
1181
|
-
purpose, canonical start-here files, boundaries, dependencies, and what must
|
|
1182
|
-
not be changed while working there. Target 50\u2013200 lines.
|
|
1183
|
-
- \`.opencode/context/conventions.md\` \u2014 repository-specific conventions:
|
|
1184
|
-
naming, patterns, error handling, testing, persistence, framework rules.
|
|
1185
|
-
Target 30\u2013150 lines.
|
|
1186
|
-
- \`.opencode/context/workflows.md\` \u2014 the few runtime flows worth tracing.
|
|
1187
|
-
Target 30\u2013150 lines.
|
|
1188
|
-
- \`.opencode/context/decisions.md\` \u2014 architectural decisions discovered
|
|
1189
|
-
during work that are not already recorded in the project's own decision
|
|
1190
|
-
log. Concise; variable length.
|
|
1191
|
-
- \`.opencode/context/active.md\` \u2014 the active backlog: work in flight, grouped
|
|
1192
|
-
by the week it was opened. Not part of the navigation map; it is described
|
|
1193
|
-
under "Task backlog" below.
|
|
1194
|
-
|
|
1195
|
-
# Current-state only
|
|
1196
|
-
|
|
1197
|
-
Every file you write states only the current arrangement and the reason it
|
|
1198
|
-
holds, in the present tense. No "used to", "previously", "before this",
|
|
1199
|
-
migration notes or deprecation narrative \u2014 history belongs in the project's
|
|
1200
|
-
changelog, not in your map. An absence that is true today is a fact worth
|
|
1201
|
-
stating, not a removal worth narrating. When a past state is worth keeping,
|
|
1202
|
-
record it briefly in \`changelog.md\` instead.
|
|
1203
|
-
|
|
1204
|
-
Never create, modify, or delete anything outside \`.opencode/context/\`. The
|
|
1205
|
-
\`edit\` permission enforces this. Do not attempt to work around it with bash.
|
|
1206
|
-
|
|
1207
|
-
To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the
|
|
1208
|
-
\`glob\` tool for it: \`glob\` does not match hidden directories, so it reports
|
|
1209
|
-
"no files" even when the map exists.
|
|
1210
|
-
|
|
1211
|
-
# Bootstrap
|
|
1212
|
-
|
|
1213
|
-
Trigger: \`.opencode/context/\` does not exist or is empty.
|
|
1214
|
-
|
|
1215
|
-
Explore progressively and stop as soon as you have a usable map:
|
|
1216
|
-
|
|
1217
|
-
1. README files and any existing architecture or decision documentation.
|
|
1218
|
-
2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
|
|
1219
|
-
\u2026) for entry points, workspaces, and dependency structure.
|
|
1220
|
-
3. The top-level directory structure \u2014 one or two levels. Name what each
|
|
1221
|
-
directory is for.
|
|
1222
|
-
4. Framework and build configuration (tsconfig, bundler config, CI, container
|
|
1223
|
-
files) to learn how things are assembled and run.
|
|
1224
|
-
5. Application entry points. Read them.
|
|
1225
|
-
6. Obvious domain / application / infrastructure boundaries and the
|
|
1226
|
-
interfaces between them.
|
|
1227
|
-
7. Only then inspect source selectively \u2014 the files that define the boundaries
|
|
1228
|
-
you found.
|
|
1229
|
-
|
|
1230
|
-
You are mapping a city, not auditing every house. Do not attempt to
|
|
1231
|
-
understand every file.
|
|
1232
|
-
|
|
1233
|
-
Then write the five files. Also create \`.opencode/context/active.md\` with its
|
|
1234
|
-
heading and no tasks. For each significant subsystem, give a short
|
|
1235
|
-
"Start here" list \u2014 at most about five canonical files, each with a one-phrase
|
|
1236
|
-
reason. This is the single most valuable thing you produce: where an agent
|
|
1237
|
-
should open its editor next.
|
|
1238
|
-
|
|
1239
|
-
# Incremental update
|
|
1240
|
-
|
|
1241
|
-
Trigger: \`.opencode/context/\` exists and work has just been done here.
|
|
1242
|
-
|
|
1243
|
-
1. Start from the files that changed, plus their immediate architectural
|
|
1244
|
-
neighborhood: what they import, who calls them, the boundary they sit on.
|
|
1245
|
-
2. Read only the context file(s) those changes could affect.
|
|
1246
|
-
3. Default to no edit. A changed file is not a changed fact: comments,
|
|
1247
|
-
formatting, renames of local variables, added tests, dependency bumps, and
|
|
1248
|
-
edits that stay inside one function rarely alter what the map should say.
|
|
1249
|
-
Edit only when the work introduced or revealed information that will
|
|
1250
|
-
materially help a future agent, such as:
|
|
1251
|
-
- a new architectural boundary or module
|
|
1252
|
-
- a new or moved canonical entry point
|
|
1253
|
-
- a changed workflow
|
|
1254
|
-
- a new repository convention
|
|
1255
|
-
- an architectural decision
|
|
1256
|
-
- a new dependency between subsystems
|
|
1257
|
-
- a renamed or relocated subsystem
|
|
1258
|
-
- an invariant not yet recorded in the map
|
|
1259
|
-
|
|
1260
|
-
Do not update documentation merely because source files changed. Do not
|
|
1261
|
-
rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
|
|
1262
|
-
that is already stated in another context file \u2014 one fact has one home, and
|
|
1263
|
-
the other files link to it. If nothing material changed, make no edits and
|
|
1264
|
-
report "no update needed".
|
|
1265
|
-
|
|
1266
|
-
Also keep \`.opencode/context/active.md\` current: record work that is in
|
|
1267
|
-
flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.
|
|
1268
|
-
|
|
1269
|
-
# Task backlog
|
|
1270
|
-
|
|
1271
|
-
\`.opencode/context/active.md\` holds the work in flight. It is a rolling
|
|
1272
|
-
window, not a history: it answers "what is open, and what just finished".
|
|
1273
|
-
|
|
1274
|
-
Format \u2014 one line per task, under the ISO week the task was opened in:
|
|
1275
|
-
|
|
1276
|
-
## 2026-W43
|
|
1277
|
-
|
|
1278
|
-
- [ ] Ship the release notes \u2014 opened 2026-W43
|
|
1279
|
-
- [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
|
|
1280
|
-
|
|
1281
|
-
The rules:
|
|
1282
|
-
|
|
1283
|
-
- A task that is not marked \`[x]\` is OPEN. An open task is never removed,
|
|
1284
|
-
however old it is.
|
|
1285
|
-
- A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\`
|
|
1286
|
-
date. Record the date it actually finished, as \`YYYY-MM-DD\`.
|
|
1287
|
-
- The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
|
|
1288
|
-
- A finished task is dropped once it is more than a week old. The plugin
|
|
1289
|
-
enforces that: record the finished task with its date and let the window
|
|
1290
|
-
close on its own. Do not delete old finished tasks by hand, and do not
|
|
1291
|
-
keep a finished task back to preserve it \u2014 the file is meant to stay
|
|
1292
|
-
small.
|
|
1293
|
-
- Never invent a task. Record work that actually happened, and mark a task
|
|
1294
|
-
finished only once it is finished.
|
|
1295
|
-
- Keep the file inside the window. If the backlog has grown past a handful
|
|
1296
|
-
of weeks, that is a signal to close finished tasks, not to add sections.
|
|
1297
|
-
|
|
1298
|
-
# Minimal exploration
|
|
1299
|
-
|
|
1300
|
-
Default shape:
|
|
1301
|
-
|
|
1302
|
-
repository structure
|
|
1303
|
-
\u2192 existing documentation
|
|
1304
|
-
\u2192 relevant entry points
|
|
1305
|
-
\u2192 relevant neighboring modules
|
|
1306
|
-
\u2192 deeper exploration only when necessary
|
|
1307
|
-
|
|
1308
|
-
Avoid:
|
|
1309
|
-
|
|
1310
|
-
repository
|
|
1311
|
-
\u2192 every directory
|
|
1312
|
-
\u2192 every file
|
|
1313
|
-
\u2192 every dependency
|
|
1314
|
-
\u2192 every caller
|
|
1315
|
-
\u2192 the whole codebase
|
|
1316
|
-
|
|
1317
|
-
Aim for minimum sufficient exploration. A few precise reads beat a broad
|
|
1318
|
-
sweep. When updating after a task, the changed files and their immediate
|
|
1319
|
-
neighborhood are almost always enough.
|
|
1320
|
-
|
|
1321
|
-
# Stay inside this project
|
|
1322
|
-
|
|
1323
|
-
The target repository is the only thing you are mapping. Everything outside its
|
|
1324
|
-
root is out of scope and is blocked:
|
|
1325
|
-
|
|
1326
|
-
- Do not read OpenCode's own configuration, prompts, plugins, session storage,
|
|
1327
|
-
or logs. They are not part of the project and hold no project facts.
|
|
1328
|
-
- Do not inspect other repositories, home directories, or system paths.
|
|
1329
|
-
- Do not treat a blocked tool call as a puzzle to solve. If a read or command
|
|
1330
|
-
is refused, use the project's own files instead and move on.
|
|
1331
|
-
- Do not search the filesystem root or use \`grep -r /\`.
|
|
1332
|
-
|
|
1333
|
-
# Source of truth
|
|
1334
|
-
|
|
1335
|
-
Source code wins. If the context map disagrees with the code:
|
|
1336
|
-
|
|
1337
|
-
1. Investigate the discrepancy.
|
|
1338
|
-
2. Trust the source.
|
|
1339
|
-
3. Correct the map.
|
|
1340
|
-
|
|
1341
|
-
Never propagate stale context. Never edit source code to match the map \u2014 you
|
|
1342
|
-
never edit source code at all.
|
|
1343
|
-
|
|
1344
|
-
# Confidence
|
|
1345
|
-
|
|
1346
|
-
Do not record assumptions as facts. If something is uncertain, mark it inline:
|
|
1347
|
-
|
|
1348
|
-
> Confidence: low \u2014 inferred from \`src/foo.ts\` naming only
|
|
1349
|
-
|
|
1350
|
-
Intent is the most common thing code cannot tell you. A single \`export { app }\`
|
|
1351
|
-
does not prove a test seam was intended, and one carefully written expression
|
|
1352
|
-
does not prove a repository convention. Mark those inferences or leave them
|
|
1353
|
-
out.
|
|
1354
|
-
|
|
1355
|
-
If a fact cannot be established with reasonable effort, omit it. A short map
|
|
1356
|
-
that is true beats a long map that is partly guesswork.
|
|
1357
|
-
|
|
1358
|
-
# Keep it small
|
|
1359
|
-
|
|
1360
|
-
The context system exists to REDUCE context consumption. Never let the map
|
|
1361
|
-
grow into an encyclopedia. Do not write:
|
|
1362
|
-
|
|
1363
|
-
- file inventories or directory listings
|
|
1364
|
-
- copied or paraphrased source code
|
|
1365
|
-
- large API descriptions
|
|
1366
|
-
- content duplicated from the project's own docs \u2014 link to them instead
|
|
1367
|
-
- generated documentation
|
|
1368
|
-
- exhaustive class or function lists
|
|
1369
|
-
- the same finding restated in several files \u2014 state a fact once, in the file
|
|
1370
|
-
that owns it, and let the others point at it
|
|
1371
|
-
- generic programming advice that would be true in any repository
|
|
1372
|
-
|
|
1373
|
-
Write conclusions, not inventories. Prefer a statement like
|
|
1374
|
-
"\`OrderService\` is the application boundary for order mutations" over a
|
|
1375
|
-
description of every method it exposes.
|
|
1376
|
-
|
|
1377
|
-
If a file grows past its target, consolidate: merge overlapping entries, drop
|
|
1378
|
-
the lowest-value ones, and link out to authoritative project docs.
|
|
1379
|
-
|
|
1380
|
-
# Review mode
|
|
1381
|
-
|
|
1382
|
-
Trigger: an explicit review (see the \`context-review\` command).
|
|
1383
|
-
|
|
1384
|
-
Audit the map files and the backlog for:
|
|
1385
|
-
|
|
1386
|
-
- stale information \u2014 verify claims against the code
|
|
1387
|
-
- contradictions between files, or between the map and the code
|
|
1388
|
-
- excessive size \u2014 consolidate toward the targets above
|
|
1389
|
-
- duplicated information \u2014 one canonical place per fact
|
|
1390
|
-
- missing canonical entry points for significant subsystems
|
|
1391
|
-
- unsupported assumptions \u2014 anything unmarked that you cannot verify
|
|
1392
|
-
- backlog tasks that are finished but carry no date \u2014 the retention window
|
|
1393
|
-
cannot age those out, so they stay forever
|
|
1394
|
-
|
|
1395
|
-
Fix problems only in the files you own. Never modify source code during a
|
|
1396
|
-
review. Report what you found, what you fixed, and what you could not
|
|
1397
|
-
verify.
|
|
1398
|
-
|
|
1399
|
-
# Reporting
|
|
1400
|
-
|
|
1401
|
-
Finish with a short report:
|
|
1402
|
-
|
|
1403
|
-
- what you inspected
|
|
1404
|
-
- what you created or changed, and why it matters for future agents
|
|
1405
|
-
- what you deliberately left out
|
|
1406
|
-
- anything you found that contradicts the code or the existing docs`,
|
|
1562
|
+
prompt: CARTOGRAPHER_PROMPT,
|
|
1407
1563
|
permission: {
|
|
1408
1564
|
"read": "allow",
|
|
1409
1565
|
"glob": "allow",
|
|
@@ -1553,12 +1709,12 @@ Report briefly what you changed and where. Do not edit any other file.`,
|
|
|
1553
1709
|
}
|
|
1554
1710
|
}
|
|
1555
1711
|
};
|
|
1556
|
-
function buildAgentSet() {
|
|
1712
|
+
function buildAgentSet(slotCount = FALLBACK_SLOTS.length) {
|
|
1557
1713
|
const set = { ...AGENTS };
|
|
1558
1714
|
for (const base of POOLED_BASES) {
|
|
1559
1715
|
const def = AGENTS[base];
|
|
1560
1716
|
if (!def) continue;
|
|
1561
|
-
for (let slot = 1; slot <=
|
|
1717
|
+
for (let slot = 1; slot <= Math.max(1, slotCount); slot++) {
|
|
1562
1718
|
const variant = {
|
|
1563
1719
|
...def,
|
|
1564
1720
|
description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
|
|
@@ -1624,11 +1780,14 @@ your system prompt.
|
|
|
1624
1780
|
Otherwise follow the bootstrap procedure in your system prompt: progressive
|
|
1625
1781
|
exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
|
|
1626
1782
|
and framework config \u2192 entry points \u2192 selective source), then write the five
|
|
1627
|
-
context files inside their
|
|
1628
|
-
|
|
1783
|
+
context files inside their line budgets, using the entry schema (provenance
|
|
1784
|
+
marker, rule of at most three lines, \`\u2014 See <path>[ \xA7Section] (verified
|
|
1785
|
+
YYYY-MM-DD)\`). Also create \`.opencode/context/active.md\` with its heading and no
|
|
1786
|
+
tasks.
|
|
1629
1787
|
|
|
1630
|
-
Remember: write conclusions, not inventories.
|
|
1631
|
-
documentation instead of
|
|
1788
|
+
Remember: write conclusions, not inventories. Point at the project's existing
|
|
1789
|
+
documentation instead of restating it. Run the drift pass and report a
|
|
1790
|
+
contradiction rather than silently choosing a side.
|
|
1632
1791
|
|
|
1633
1792
|
Report what you created or updated, what you deliberately left out, and
|
|
1634
1793
|
anything that contradicts the code or existing docs.`
|
|
@@ -1654,7 +1813,11 @@ or an invariant not yet recorded.
|
|
|
1654
1813
|
|
|
1655
1814
|
Do not edit documentation just because source files changed. No
|
|
1656
1815
|
timestamp-only edits. If nothing material changed, make no edits and report
|
|
1657
|
-
"no update needed"
|
|
1816
|
+
"no update needed".
|
|
1817
|
+
|
|
1818
|
+
Every new or changed entry uses the schema in your system prompt and stays
|
|
1819
|
+
under the file's line budget. If a file is already over budget, consolidate
|
|
1820
|
+
before adding.`
|
|
1658
1821
|
},
|
|
1659
1822
|
"context-review": {
|
|
1660
1823
|
description: "Audit .opencode/context/ for staleness, contradictions, excess size, duplication, and missing entry points.",
|
|
@@ -1662,20 +1825,29 @@ timestamp-only edits. If nothing material changed, make no edits and report
|
|
|
1662
1825
|
subtask: true,
|
|
1663
1826
|
template: `Context review.
|
|
1664
1827
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1828
|
+
Follow the review procedure and entry schema in your system prompt. Everything
|
|
1829
|
+
outside \`.opencode/context/\` is read-only for this run. Do not modify source
|
|
1830
|
+
code under any circumstances.
|
|
1667
1831
|
|
|
1668
1832
|
Audit the context files and the backlog for:
|
|
1669
1833
|
|
|
1670
|
-
-
|
|
1671
|
-
|
|
1672
|
-
-
|
|
1673
|
-
|
|
1834
|
+
- schema \u2014 every entry has one provenance marker, at most three lines, a
|
|
1835
|
+
pointer, and a \`(verified YYYY-MM-DD)\` date
|
|
1836
|
+
- budget \u2014 every file is within its non-blank-line cap; consolidate rather
|
|
1837
|
+
than exceed it
|
|
1838
|
+
- stale information \u2014 verify claims against the actual code and the cited page
|
|
1839
|
+
- contradictions between context files, or between the map and a page \u2014 mark
|
|
1840
|
+
\`[disputed]\` and report both sides; never silently prefer one
|
|
1841
|
+
- dangling pointers \u2014 every cited path and \xA7section must exist
|
|
1842
|
+
- unarchived content \u2014 an entry with no source in the tree; report it and keep
|
|
1843
|
+
it, because it may be the only copy
|
|
1844
|
+
- restatement \u2014 a table, a fenced block, or a long verbatim span copied from a
|
|
1845
|
+
page
|
|
1674
1846
|
- duplicated information \u2014 one canonical place per fact; link to the
|
|
1675
1847
|
project's own docs rather than restating them
|
|
1676
1848
|
- missing canonical entry points for significant subsystems
|
|
1677
|
-
- unsupported assumptions \u2014 anything
|
|
1678
|
-
|
|
1849
|
+
- unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
|
|
1850
|
+
should be \`[provisional]\`
|
|
1679
1851
|
- backlog tasks that are finished but carry no date \u2014 the retention window
|
|
1680
1852
|
cannot age those out, so they stay forever
|
|
1681
1853
|
|
|
@@ -1699,7 +1871,7 @@ Follow the rules for the file in your system prompt, then report what you change
|
|
|
1699
1871
|
Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
1700
1872
|
|
|
1701
1873
|
1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
|
|
1702
|
-
slot's load against its
|
|
1874
|
+
slot's load against its budget (its weight), and which models
|
|
1703
1875
|
are cooling.
|
|
1704
1876
|
|
|
1705
1877
|
2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
|
|
@@ -1736,10 +1908,10 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1736
1908
|
|
|
1737
1909
|
2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
|
|
1738
1910
|
- one slot per line: \`<provider>/<model-id> [weight]\`
|
|
1739
|
-
-
|
|
1740
|
-
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget
|
|
1911
|
+
- any number of slots; a model may appear on more than one line, and each line is its own slot with its own budget
|
|
1912
|
+
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget \u2014 how many concurrent sessions it may hold. The pool's total capacity is the sum of the weights. NOT a priority; pick order follows line order.
|
|
1741
1913
|
- \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
|
|
1742
|
-
If the instruction names a model ambiguously,
|
|
1914
|
+
If the instruction names a model ambiguously, stop and ask rather than guessing.
|
|
1743
1915
|
|
|
1744
1916
|
3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
|
|
1745
1917
|
|
|
@@ -1765,6 +1937,11 @@ $ARGUMENTS
|
|
|
1765
1937
|
|
|
1766
1938
|
Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
|
|
1767
1939
|
},
|
|
1940
|
+
"reprocess-timeline": {
|
|
1941
|
+
description: "Rebuild this session's timeline from its full transcript",
|
|
1942
|
+
agent: "implement",
|
|
1943
|
+
template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
|
|
1944
|
+
},
|
|
1768
1945
|
"image-in-cb": {
|
|
1769
1946
|
description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
|
|
1770
1947
|
template: "$ARGUMENTS"
|
|
@@ -1816,9 +1993,9 @@ function compareSemver(a, b) {
|
|
|
1816
1993
|
}
|
|
1817
1994
|
return 0;
|
|
1818
1995
|
}
|
|
1819
|
-
function readInstalledVersion(packageRoot, read = (
|
|
1996
|
+
function readInstalledVersion(packageRoot, read = (path5) => {
|
|
1820
1997
|
try {
|
|
1821
|
-
return readFileSync3(
|
|
1998
|
+
return readFileSync3(path5, "utf8");
|
|
1822
1999
|
} catch {
|
|
1823
2000
|
return void 0;
|
|
1824
2001
|
}
|
|
@@ -1876,17 +2053,17 @@ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
1876
2053
|
clearTimeout(timer);
|
|
1877
2054
|
}
|
|
1878
2055
|
}
|
|
1879
|
-
function readState(
|
|
2056
|
+
function readState(path5 = STATE_PATH) {
|
|
1880
2057
|
try {
|
|
1881
|
-
return JSON.parse(readFileSync3(
|
|
2058
|
+
return JSON.parse(readFileSync3(path5, "utf8"));
|
|
1882
2059
|
} catch {
|
|
1883
2060
|
return {};
|
|
1884
2061
|
}
|
|
1885
2062
|
}
|
|
1886
|
-
function writeState(state2,
|
|
2063
|
+
function writeState(state2, path5 = STATE_PATH) {
|
|
1887
2064
|
try {
|
|
1888
|
-
mkdirSync2(dirname2(
|
|
1889
|
-
writeFileSync2(
|
|
2065
|
+
mkdirSync2(dirname2(path5), { recursive: true });
|
|
2066
|
+
writeFileSync2(path5, JSON.stringify(state2));
|
|
1890
2067
|
} catch {
|
|
1891
2068
|
}
|
|
1892
2069
|
}
|
|
@@ -1899,39 +2076,39 @@ var isProcessAlive = (pid) => {
|
|
|
1899
2076
|
return error.code === "EPERM";
|
|
1900
2077
|
}
|
|
1901
2078
|
};
|
|
1902
|
-
function acquireLock(
|
|
2079
|
+
function acquireLock(path5 = LOCK_PATH, now = Date.now()) {
|
|
1903
2080
|
const claim = () => {
|
|
1904
|
-
const fd = openSync(
|
|
2081
|
+
const fd = openSync(path5, "wx");
|
|
1905
2082
|
writeSync(fd, String(process.pid));
|
|
1906
2083
|
closeSync(fd);
|
|
1907
2084
|
return () => {
|
|
1908
2085
|
try {
|
|
1909
|
-
unlinkSync2(
|
|
2086
|
+
unlinkSync2(path5);
|
|
1910
2087
|
} catch {
|
|
1911
2088
|
}
|
|
1912
2089
|
};
|
|
1913
2090
|
};
|
|
1914
2091
|
try {
|
|
1915
|
-
mkdirSync2(dirname2(
|
|
2092
|
+
mkdirSync2(dirname2(path5), { recursive: true });
|
|
1916
2093
|
return claim();
|
|
1917
2094
|
} catch {
|
|
1918
2095
|
let orphaned = false;
|
|
1919
2096
|
try {
|
|
1920
|
-
const pid = Number.parseInt(readFileSync3(
|
|
2097
|
+
const pid = Number.parseInt(readFileSync3(path5, "utf8").trim(), 10);
|
|
1921
2098
|
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
1922
2099
|
} catch {
|
|
1923
2100
|
orphaned = true;
|
|
1924
2101
|
}
|
|
1925
2102
|
if (!orphaned) {
|
|
1926
2103
|
try {
|
|
1927
|
-
orphaned = now - statSync2(
|
|
2104
|
+
orphaned = now - statSync2(path5).mtimeMs > LOCK_STALE_MS;
|
|
1928
2105
|
} catch {
|
|
1929
2106
|
orphaned = true;
|
|
1930
2107
|
}
|
|
1931
2108
|
}
|
|
1932
2109
|
if (!orphaned) return void 0;
|
|
1933
2110
|
try {
|
|
1934
|
-
unlinkSync2(
|
|
2111
|
+
unlinkSync2(path5);
|
|
1935
2112
|
} catch {
|
|
1936
2113
|
return void 0;
|
|
1937
2114
|
}
|
|
@@ -2058,6 +2235,7 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
2058
2235
|
var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
|
|
2059
2236
|
var PROBE_PROMPT = "ok";
|
|
2060
2237
|
var PROBE_TIMEOUT_MS = 45e3;
|
|
2238
|
+
var REASON_UNRESPONSIVE = "no answer";
|
|
2061
2239
|
var failureText = (error) => {
|
|
2062
2240
|
if (error === void 0 || error === null) return "";
|
|
2063
2241
|
if (typeof error === "string") return error;
|
|
@@ -2070,32 +2248,35 @@ var failureText = (error) => {
|
|
|
2070
2248
|
var shortReason = (text) => {
|
|
2071
2249
|
if (!text) return "failed";
|
|
2072
2250
|
if (isModelMissingText(text)) return MODEL_MISSING_REASON;
|
|
2251
|
+
if (isAuthFailureText(text)) return AUTH_FAILURE_REASON;
|
|
2073
2252
|
return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
|
|
2074
2253
|
};
|
|
2075
2254
|
var probeOne = async (client, opts, model, leftSeconds) => {
|
|
2076
2255
|
const { state: s, directory } = opts;
|
|
2077
2256
|
const { provider, id: modelID } = splitModel(model);
|
|
2078
|
-
const settle = (ok, reason, left) => ({
|
|
2257
|
+
const settle = (ok, reason, left, kind, strikes) => ({
|
|
2079
2258
|
model,
|
|
2080
2259
|
ok,
|
|
2081
2260
|
reason,
|
|
2082
2261
|
hadSeconds: leftSeconds,
|
|
2083
|
-
leftSeconds: left
|
|
2262
|
+
leftSeconds: left,
|
|
2263
|
+
kind,
|
|
2264
|
+
strikes
|
|
2084
2265
|
});
|
|
2085
2266
|
const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
|
|
2086
|
-
if (!created) return settle(false, "no probe session", leftSeconds);
|
|
2267
|
+
if (!created) return settle(false, "no probe session", leftSeconds, "cooling");
|
|
2087
2268
|
markProbe(s, created);
|
|
2088
2269
|
let timer;
|
|
2089
2270
|
try {
|
|
2090
2271
|
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
2091
2272
|
const clock = opts.now ?? Date.now;
|
|
2092
2273
|
let timedOut = false;
|
|
2093
|
-
const deadline = new Promise((
|
|
2274
|
+
const deadline = new Promise((resolve5) => {
|
|
2094
2275
|
timer = setTimeout(() => {
|
|
2095
2276
|
timedOut = true;
|
|
2096
2277
|
void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
|
|
2097
2278
|
});
|
|
2098
|
-
|
|
2279
|
+
resolve5(void 0);
|
|
2099
2280
|
}, timeoutMs);
|
|
2100
2281
|
});
|
|
2101
2282
|
const answer = client.session.prompt({
|
|
@@ -2114,20 +2295,37 @@ var probeOne = async (client, opts, model, leftSeconds) => {
|
|
|
2114
2295
|
const failure = result?.error ?? result?.data?.info?.error;
|
|
2115
2296
|
if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
|
|
2116
2297
|
clearCooldowns(s, [model]);
|
|
2117
|
-
return settle(true, "answered", 0);
|
|
2298
|
+
return settle(true, "answered", 0, "answered");
|
|
2118
2299
|
}
|
|
2119
2300
|
const text = failureText(failure);
|
|
2301
|
+
let kind = "cooling";
|
|
2302
|
+
let strikes;
|
|
2120
2303
|
if (isModelMissingText(text)) {
|
|
2121
|
-
recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
|
|
2304
|
+
const recorded = recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
|
|
2305
|
+
strikes = recorded?.strikes;
|
|
2306
|
+
kind = "benched";
|
|
2307
|
+
} else if (isAuthFailureText(text)) {
|
|
2308
|
+
const recorded = recordStrike(s, model, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
|
|
2309
|
+
strikes = recorded?.strikes;
|
|
2310
|
+
kind = "benched";
|
|
2311
|
+
} else if (timedOut || text === "") {
|
|
2312
|
+
const recorded = recordStrike(s, model, REASON_UNRESPONSIVE, 0);
|
|
2313
|
+
strikes = recorded?.strikes;
|
|
2314
|
+
kind = "benched";
|
|
2122
2315
|
} else {
|
|
2123
2316
|
const stated = statedCooldown(text);
|
|
2124
|
-
if (stated !== void 0)
|
|
2317
|
+
if (stated !== void 0) {
|
|
2318
|
+
const recorded = recordStrike(s, model, shortReason(text), stated);
|
|
2319
|
+
strikes = recorded?.strikes;
|
|
2320
|
+
}
|
|
2125
2321
|
}
|
|
2126
2322
|
const still = readLimits(s).get(model);
|
|
2127
2323
|
return settle(
|
|
2128
2324
|
false,
|
|
2129
2325
|
timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
|
|
2130
|
-
still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
|
|
2326
|
+
still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0,
|
|
2327
|
+
kind,
|
|
2328
|
+
strikes
|
|
2131
2329
|
);
|
|
2132
2330
|
} finally {
|
|
2133
2331
|
if (timer) clearTimeout(timer);
|
|
@@ -2212,6 +2410,24 @@ var SLOT_WATCH_MS = 1e3;
|
|
|
2212
2410
|
var SLOT_WATCH_DEBOUNCE_MS = 250;
|
|
2213
2411
|
var ABORT_TIMEOUT_MS = 1e4;
|
|
2214
2412
|
var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
|
|
2413
|
+
var reportText = (report) => {
|
|
2414
|
+
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
2415
|
+
const answered = report.outcomes.filter((outcome) => outcome.ok).length;
|
|
2416
|
+
const benched = report.outcomes.filter((outcome) => outcome.kind === "benched").length;
|
|
2417
|
+
const cooling = report.outcomes.length - answered - benched;
|
|
2418
|
+
return [
|
|
2419
|
+
...report.outcomes.map((outcome) => {
|
|
2420
|
+
if (outcome.ok) return `${outcome.model}: answered, cooldown cleared`;
|
|
2421
|
+
if (outcome.kind === "benched") {
|
|
2422
|
+
const window = outcome.leftSeconds > 0 ? ` for ${Math.round(outcome.leftSeconds)}s` : "";
|
|
2423
|
+
const strike = outcome.strikes !== void 0 ? ` (strike ${outcome.strikes})` : "";
|
|
2424
|
+
return `${outcome.model}: ${outcome.reason} \u2014 benched${window}${strike}`;
|
|
2425
|
+
}
|
|
2426
|
+
return `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`;
|
|
2427
|
+
}),
|
|
2428
|
+
`summary: ${answered} answered, ${benched} benched, ${cooling} still cooling`
|
|
2429
|
+
].join("\n");
|
|
2430
|
+
};
|
|
2215
2431
|
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
2216
2432
|
const opts = options ?? {};
|
|
2217
2433
|
const s = state(opts);
|
|
@@ -2249,6 +2465,36 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2249
2465
|
return { ok: false, why: String(error) };
|
|
2250
2466
|
}
|
|
2251
2467
|
};
|
|
2468
|
+
const abortReports = /* @__PURE__ */ new Map();
|
|
2469
|
+
const abortKind = (reason) => isQuotaReason(reason) || isLimitText(reason) ? "rate-limit" : "provider error";
|
|
2470
|
+
const abortLabel = (kind, elapsedMs) => {
|
|
2471
|
+
const seconds = Math.max(1, Math.round(elapsedMs / 1e3));
|
|
2472
|
+
if (kind === "no-answer") return `no answer in ${seconds}s`;
|
|
2473
|
+
if (kind === "rate-limit") return `rate limited after ${seconds}s`;
|
|
2474
|
+
if (kind === "empty response") return `empty response after ${seconds}s`;
|
|
2475
|
+
if (kind === "permission") return `permission denied after ${seconds}s`;
|
|
2476
|
+
return `provider error after ${seconds}s`;
|
|
2477
|
+
};
|
|
2478
|
+
const noteAbort = async (key, report) => {
|
|
2479
|
+
abortReports.set(key, report);
|
|
2480
|
+
if (abortReports.size > 32) {
|
|
2481
|
+
const oldest = abortReports.keys().next().value;
|
|
2482
|
+
if (oldest !== void 0) abortReports.delete(oldest);
|
|
2483
|
+
}
|
|
2484
|
+
await log("warn", report.message, {
|
|
2485
|
+
slot: report.slot,
|
|
2486
|
+
model: report.model,
|
|
2487
|
+
elapsedMs: report.elapsedMs,
|
|
2488
|
+
failure: report.kind,
|
|
2489
|
+
released: report.released
|
|
2490
|
+
});
|
|
2491
|
+
try {
|
|
2492
|
+
await client.tui?.showToast?.({
|
|
2493
|
+
body: { title: "pool spawn", message: report.message, variant: "warning", duration: 8e3 }
|
|
2494
|
+
});
|
|
2495
|
+
} catch {
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2252
2498
|
const MAX_ABORT_ATTEMPTS = 3;
|
|
2253
2499
|
const coolForHang = async (claim, log2) => {
|
|
2254
2500
|
const now = Date.now();
|
|
@@ -2304,6 +2550,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2304
2550
|
const kill = await abortSession(sessionID);
|
|
2305
2551
|
if (kill.ok) {
|
|
2306
2552
|
release(s, key);
|
|
2553
|
+
const elapsedMs = Date.now() - claim.t;
|
|
2554
|
+
const kind = abortKind(reason);
|
|
2555
|
+
await noteAbort(key, {
|
|
2556
|
+
slot: claim.k,
|
|
2557
|
+
model: claim.m,
|
|
2558
|
+
elapsedMs,
|
|
2559
|
+
kind,
|
|
2560
|
+
released: true,
|
|
2561
|
+
message: `slot ${claim.k} ${claim.m} \u2014 ${abortLabel(kind, elapsedMs)}; claim released`
|
|
2562
|
+
});
|
|
2307
2563
|
await log(
|
|
2308
2564
|
"warn",
|
|
2309
2565
|
`limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
|
|
@@ -2372,6 +2628,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2372
2628
|
reason === "overran" ? `overran ${claim.v} on ${claim.m} after ${age}s; aborted session ${claim.g}` : `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
|
|
2373
2629
|
{ base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
|
|
2374
2630
|
);
|
|
2631
|
+
const elapsedMs = Date.now() - claim.t;
|
|
2632
|
+
await noteAbort(key, {
|
|
2633
|
+
slot: claim.k,
|
|
2634
|
+
model: claim.m,
|
|
2635
|
+
elapsedMs,
|
|
2636
|
+
kind: "no-answer",
|
|
2637
|
+
released: true,
|
|
2638
|
+
message: `slot ${claim.k} ${claim.m} \u2014 ${reason === "overran" ? `overran after ${Math.max(1, Math.round(elapsedMs / 1e3))}s` : abortLabel("no-answer", elapsedMs)}; claim released`
|
|
2639
|
+
});
|
|
2375
2640
|
await coolForHang(claim, log);
|
|
2376
2641
|
continue;
|
|
2377
2642
|
}
|
|
@@ -2423,17 +2688,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2423
2688
|
if (report.outcomes.length === 0) return "nothing cooling to probe";
|
|
2424
2689
|
const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
|
|
2425
2690
|
if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
|
|
2691
|
+
const authBenched = report.outcomes.filter((outcome) => outcome.reason === AUTH_FAILURE_REASON).length;
|
|
2692
|
+
if (authBenched > 0) parts.push(`benched ${authBenched} auth-failed`);
|
|
2426
2693
|
return parts.join(", ");
|
|
2427
2694
|
};
|
|
2428
|
-
const reportText = (report) => {
|
|
2429
|
-
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
2430
|
-
return [
|
|
2431
|
-
...report.outcomes.map(
|
|
2432
|
-
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
|
|
2433
|
-
),
|
|
2434
|
-
report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
|
|
2435
|
-
].join("\n");
|
|
2436
|
-
};
|
|
2437
2695
|
const announce = async (report) => {
|
|
2438
2696
|
if (!report.changed) return;
|
|
2439
2697
|
const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
|
|
@@ -2473,10 +2731,23 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2473
2731
|
for (const slot of slots) {
|
|
2474
2732
|
for (const base of BASES) {
|
|
2475
2733
|
const name = agentFor(base, slot.index);
|
|
2476
|
-
|
|
2734
|
+
let def = agents?.[name];
|
|
2477
2735
|
if (!def) {
|
|
2478
|
-
|
|
2479
|
-
|
|
2736
|
+
const baseDef = agents?.[base];
|
|
2737
|
+
if (!agents || !baseDef) {
|
|
2738
|
+
await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
const created = {
|
|
2742
|
+
...baseDef,
|
|
2743
|
+
description: `Hidden pool slot ${slot.index} for ${base}. Its model is set by the pool at config time. Spawn '${base}' and let the pool route.`,
|
|
2744
|
+
hidden: true,
|
|
2745
|
+
mode: "primary"
|
|
2746
|
+
};
|
|
2747
|
+
delete created.model;
|
|
2748
|
+
agents[name] = created;
|
|
2749
|
+
def = created;
|
|
2750
|
+
await log("info", `declared variant ${name} for slot ${slot.index} (table grew after startup)`);
|
|
2480
2751
|
}
|
|
2481
2752
|
if (def.model && def.model !== slot.model) {
|
|
2482
2753
|
await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
|
|
@@ -2558,7 +2829,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2558
2829
|
const provider = typeof action?.provider === "string" ? action.provider : void 0;
|
|
2559
2830
|
const reason2 = action?.reason ?? "";
|
|
2560
2831
|
const message = typeof status2?.message === "string" ? status2.message : "";
|
|
2561
|
-
const
|
|
2832
|
+
const auth2 = isAuthFailureText(reason2) || isAuthFailureText(message);
|
|
2833
|
+
const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message) || auth2;
|
|
2562
2834
|
if (status2?.type === "retry" && limited2) {
|
|
2563
2835
|
if (sessionID && isProbe(s, sessionID)) {
|
|
2564
2836
|
await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
@@ -2566,9 +2838,9 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2566
2838
|
}
|
|
2567
2839
|
const own = claimFor(sessionID);
|
|
2568
2840
|
const outage = isUnavailableText(message) && !isQuotaReason(reason2);
|
|
2569
|
-
const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
|
|
2841
|
+
const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
|
|
2570
2842
|
if (provider) {
|
|
2571
|
-
const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
2843
|
+
const until = auth2 ? Date.now() + AUTH_FAILURE_COOLDOWN_MS : outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
2572
2844
|
const cooled = coolProvider(s, provider, until, named);
|
|
2573
2845
|
if (cooled.length > 0) {
|
|
2574
2846
|
await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
|
|
@@ -2578,7 +2850,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2578
2850
|
});
|
|
2579
2851
|
}
|
|
2580
2852
|
} else if (own) {
|
|
2581
|
-
if (
|
|
2853
|
+
if (auth2) {
|
|
2854
|
+
const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
|
|
2855
|
+
if (strike2) {
|
|
2856
|
+
await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
|
|
2857
|
+
model: own.claim.m,
|
|
2858
|
+
slot: own.claim.k,
|
|
2859
|
+
session: sessionID
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
} else if (outage) {
|
|
2582
2863
|
const transient = coolUnavailable(s, own.claim.m);
|
|
2583
2864
|
if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2584
2865
|
} else {
|
|
@@ -2611,14 +2892,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2611
2892
|
return;
|
|
2612
2893
|
}
|
|
2613
2894
|
const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
|
|
2895
|
+
const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
|
|
2614
2896
|
const limited2 = isLimitText(haystack2) || status2 === 429;
|
|
2615
2897
|
const outage = isUnavailableText(haystack2);
|
|
2616
|
-
if (!limited2 && !outage) return;
|
|
2898
|
+
if (!limited2 && !outage && !auth2) return;
|
|
2617
2899
|
const own = claimFor(sessionID);
|
|
2618
|
-
const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
|
|
2900
|
+
const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
|
|
2619
2901
|
let stated2;
|
|
2620
2902
|
if (own) {
|
|
2621
|
-
if (
|
|
2903
|
+
if (auth2) {
|
|
2904
|
+
stated2 = AUTH_FAILURE_COOLDOWN_MS;
|
|
2905
|
+
const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, stated2);
|
|
2906
|
+
if (strike2) {
|
|
2907
|
+
await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
|
|
2908
|
+
model: own.claim.m,
|
|
2909
|
+
slot: own.claim.k,
|
|
2910
|
+
session: sessionID
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
} else if (outage) {
|
|
2622
2914
|
const transient = coolUnavailable(s, own.claim.m);
|
|
2623
2915
|
if (transient) {
|
|
2624
2916
|
await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
|
|
@@ -2654,24 +2946,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2654
2946
|
if (!info || info.role !== "assistant" || !info.error) return;
|
|
2655
2947
|
const haystack = JSON.stringify(info.error) ?? "";
|
|
2656
2948
|
const status = typeof info.error.data?.statusCode === "number" ? info.error.data.statusCode : void 0;
|
|
2949
|
+
const auth = isAuthFailureText(haystack) || status === 401 || status === 403;
|
|
2657
2950
|
const limited = isLimitText(haystack) || status === 429;
|
|
2658
2951
|
const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
|
|
2659
2952
|
const transientGateway = gateway5xx || isTransientGatewayText(haystack);
|
|
2660
|
-
if (!limited && !transientGateway) return;
|
|
2953
|
+
if (!limited && !transientGateway && !auth) return;
|
|
2661
2954
|
const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
|
|
2662
2955
|
if (info.sessionID && isProbe(s, info.sessionID)) {
|
|
2663
2956
|
await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
|
|
2664
2957
|
return;
|
|
2665
2958
|
}
|
|
2666
2959
|
const ownMsg = claimFor(info.sessionID);
|
|
2667
|
-
if (isUnavailableText(haystack) || !limited && transientGateway) {
|
|
2960
|
+
if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
|
|
2668
2961
|
const transient = coolUnavailable(s, model);
|
|
2669
2962
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2670
2963
|
await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
|
|
2671
2964
|
return;
|
|
2672
2965
|
}
|
|
2673
|
-
const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
|
|
2674
|
-
const reason = limitReason(haystack);
|
|
2966
|
+
const stated = auth ? AUTH_FAILURE_COOLDOWN_MS : statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
|
|
2967
|
+
const reason = auth ? AUTH_FAILURE_REASON : limitReason(haystack);
|
|
2675
2968
|
const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
|
|
2676
2969
|
if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
|
|
2677
2970
|
await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
|
|
@@ -2712,7 +3005,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2712
3005
|
weight: slot.weight,
|
|
2713
3006
|
model: slot.model,
|
|
2714
3007
|
ceiling: slotCeiling(slot),
|
|
2715
|
-
load: (snap.counts.get(slot.
|
|
3008
|
+
load: (snap.counts.get(slot.index) ?? 0) + 1
|
|
2716
3009
|
});
|
|
2717
3010
|
output.args.subagent_type = variant;
|
|
2718
3011
|
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
@@ -2729,9 +3022,21 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2729
3022
|
await log("error", `routing failed, spawn unchanged: ${String(error)}`);
|
|
2730
3023
|
}
|
|
2731
3024
|
},
|
|
2732
|
-
"tool.execute.after": async (input) => {
|
|
3025
|
+
"tool.execute.after": async (input, output) => {
|
|
2733
3026
|
if (input.tool !== "task") return;
|
|
2734
|
-
|
|
3027
|
+
const key = claimKey(input.sessionID, input.callID);
|
|
3028
|
+
release(s, key);
|
|
3029
|
+
const report = abortReports.get(key);
|
|
3030
|
+
if (!report) return;
|
|
3031
|
+
abortReports.delete(key);
|
|
3032
|
+
const detail = [
|
|
3033
|
+
`pool spawn: ${report.message}`,
|
|
3034
|
+
`slot ${report.slot}; model ${report.model}; elapsed ${report.elapsedMs}ms; failure ${report.kind}; claim ${report.released ? "released" : "held"}`
|
|
3035
|
+
].join("\n");
|
|
3036
|
+
const text = typeof output.output === "string" ? output.output : "";
|
|
3037
|
+
output.output = /task\s+cancel+ed/i.test(text) ? detail : text ? `${text}
|
|
3038
|
+
|
|
3039
|
+
${detail}` : detail;
|
|
2735
3040
|
},
|
|
2736
3041
|
tool: {
|
|
2737
3042
|
pool_recheck: tool({
|
|
@@ -2754,18 +3059,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2754
3059
|
const snap = snapshot(s);
|
|
2755
3060
|
const pass = poolPass(s, snap);
|
|
2756
3061
|
const now = Date.now();
|
|
2757
|
-
const
|
|
2758
|
-
const add = (
|
|
2759
|
-
const entry =
|
|
3062
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
3063
|
+
const add = (k, base) => {
|
|
3064
|
+
const entry = bySlot.get(k) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
|
|
2760
3065
|
entry.total++;
|
|
2761
3066
|
entry.bases.set(base, (entry.bases.get(base) ?? 0) + 1);
|
|
2762
|
-
|
|
3067
|
+
bySlot.set(k, entry);
|
|
2763
3068
|
};
|
|
2764
|
-
for (const claim of snap.own.values()) add(claim.
|
|
2765
|
-
for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.
|
|
3069
|
+
for (const claim of snap.own.values()) add(claim.k, claim.v);
|
|
3070
|
+
for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.k, claim.v);
|
|
2766
3071
|
const next = pickSlot(s, snap);
|
|
2767
3072
|
const line = (slot) => {
|
|
2768
|
-
const entry =
|
|
3073
|
+
const entry = bySlot.get(slot.index);
|
|
2769
3074
|
const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
|
|
2770
3075
|
const strike = snap.cooling.get(slot.model);
|
|
2771
3076
|
const status = s.catalog.get(slot.model);
|
|
@@ -2780,14 +3085,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2780
3085
|
};
|
|
2781
3086
|
const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
|
|
2782
3087
|
const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
|
|
3088
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3089
|
+
for (const slot of slots) {
|
|
3090
|
+
const name = slotBucket(slot);
|
|
3091
|
+
const entry = buckets.get(name) ?? { seats: 0, slots: 0, cooling: false };
|
|
3092
|
+
entry.seats += slot.weight;
|
|
3093
|
+
entry.slots++;
|
|
3094
|
+
if (snap.cooling.has(slot.model)) entry.cooling = true;
|
|
3095
|
+
buckets.set(name, entry);
|
|
3096
|
+
}
|
|
3097
|
+
const sharedBuckets = [...buckets].filter(([, entry]) => entry.slots > 1);
|
|
2783
3098
|
const verdicts = [...snap.own.values()].map((claim) => condemnReason(s, claim, now));
|
|
2784
3099
|
const hung = verdicts.filter((verdict) => verdict === "hung").length;
|
|
2785
3100
|
const overran = verdicts.filter((verdict) => verdict === "overran").length;
|
|
2786
3101
|
return {
|
|
2787
3102
|
title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
|
|
2788
3103
|
output: [
|
|
2789
|
-
`shared pool over ${slots.length}
|
|
2790
|
-
|
|
3104
|
+
`shared pool over ${slots.length} slot(s) from ${s.origin}; state ${pass}; bases: ${BASES.join(", ")}
|
|
3105
|
+
one ordered pass in slot order: each slot fills to its weight, then overflow goes to the least-loaded slot. Capacity is the sum of the weights; repeat a model on another line to give it more (FULL = at weight).`,
|
|
3106
|
+
...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
|
|
2791
3107
|
...slots.map(line),
|
|
2792
3108
|
"",
|
|
2793
3109
|
"by process:",
|
|
@@ -2823,9 +3139,9 @@ two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 t
|
|
|
2823
3139
|
});
|
|
2824
3140
|
|
|
2825
3141
|
// src/plugins/context-autoupdate.ts
|
|
2826
|
-
import { existsSync as
|
|
3142
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync6, statSync as statSync4, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2827
3143
|
import { homedir as homedir3 } from "node:os";
|
|
2828
|
-
import * as
|
|
3144
|
+
import * as path3 from "node:path";
|
|
2829
3145
|
|
|
2830
3146
|
// src/lib/active-backlog.ts
|
|
2831
3147
|
var ACTIVE_BACKLOG_FILE = "active.md";
|
|
@@ -2922,10 +3238,498 @@ var normalizeBacklog = (text, now = Date.now()) => {
|
|
|
2922
3238
|
continue;
|
|
2923
3239
|
}
|
|
2924
3240
|
}
|
|
2925
|
-
kept.push(entry);
|
|
3241
|
+
kept.push(entry);
|
|
3242
|
+
}
|
|
3243
|
+
return { text: renderBacklog(kept), removed };
|
|
3244
|
+
};
|
|
3245
|
+
|
|
3246
|
+
// src/lib/map-schema.ts
|
|
3247
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3248
|
+
import * as path from "node:path";
|
|
3249
|
+
var PROVISIONAL_DAYS = 30;
|
|
3250
|
+
var MAP_SPECS = [
|
|
3251
|
+
{
|
|
3252
|
+
file: "architecture.md",
|
|
3253
|
+
charter: "cross-subsystem structure: layers, boundaries, entry points, dependency rules",
|
|
3254
|
+
budget: 150,
|
|
3255
|
+
minEntries: 4,
|
|
3256
|
+
kind: "entries",
|
|
3257
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3258
|
+
sectionRequired: true
|
|
3259
|
+
},
|
|
3260
|
+
{
|
|
3261
|
+
file: "contexts.md",
|
|
3262
|
+
charter: "subsystem boundaries: purpose, start-here files, what must not change",
|
|
3263
|
+
budget: 200,
|
|
3264
|
+
minEntries: 3,
|
|
3265
|
+
kind: "entries",
|
|
3266
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3267
|
+
sectionRequired: true
|
|
3268
|
+
},
|
|
3269
|
+
{
|
|
3270
|
+
file: "conventions.md",
|
|
3271
|
+
charter: "rules to follow before writing code",
|
|
3272
|
+
budget: 150,
|
|
3273
|
+
minEntries: 3,
|
|
3274
|
+
kind: "entries",
|
|
3275
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3276
|
+
sectionRequired: true
|
|
3277
|
+
},
|
|
3278
|
+
{
|
|
3279
|
+
file: "workflows.md",
|
|
3280
|
+
charter: "runtime behaviour worth tracing",
|
|
3281
|
+
budget: 150,
|
|
3282
|
+
minEntries: 2,
|
|
3283
|
+
kind: "entries",
|
|
3284
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3285
|
+
sectionRequired: true
|
|
3286
|
+
},
|
|
3287
|
+
{
|
|
3288
|
+
file: "decisions.md",
|
|
3289
|
+
charter: "architectural constraints that bind future work",
|
|
3290
|
+
budget: 120,
|
|
3291
|
+
minEntries: 1,
|
|
3292
|
+
kind: "entries",
|
|
3293
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3294
|
+
sectionRequired: true
|
|
3295
|
+
},
|
|
3296
|
+
{
|
|
3297
|
+
file: ACTIVE_BACKLOG_FILE,
|
|
3298
|
+
charter: "work in flight, grouped by the ISO week it was opened",
|
|
3299
|
+
budget: 60,
|
|
3300
|
+
minEntries: 0,
|
|
3301
|
+
kind: "backlog",
|
|
3302
|
+
provisionalDays: PROVISIONAL_DAYS,
|
|
3303
|
+
sectionRequired: false
|
|
3304
|
+
}
|
|
3305
|
+
];
|
|
3306
|
+
var MAP_SPEC_BY_FILE = new Map(MAP_SPECS.map((spec) => [spec.file, spec]));
|
|
3307
|
+
var MAP_FILES = MAP_SPECS.map((spec) => spec.file);
|
|
3308
|
+
var NAVIGATION_FILES = MAP_SPECS.filter((spec) => spec.kind === "entries").map((spec) => spec.file);
|
|
3309
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
3310
|
+
var ENTRY_START = /^([-*+])\s+\[(binding|provisional|disputed)\]\s+(.*)$/i;
|
|
3311
|
+
var CONTINUATION = /^\s{2,}\S/;
|
|
3312
|
+
var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
3313
|
+
var FENCE = /^\s*(?:```|~~~)/;
|
|
3314
|
+
var TABLE_ROW = /^\s*\|.*\|\s*$/;
|
|
3315
|
+
var TABLE_SEPARATOR = /^\s*\|[\s:|-]*-{3,}[\s:|-]*\|\s*$/;
|
|
3316
|
+
var VERIFIED = /\(verified\s+(\d{4}-\d{2}-\d{2})\)\s*$/i;
|
|
3317
|
+
var POINTER = /(?:—|–|→|->)\s*See\s+`([^`\n]+)`(?:\s*§\s*([^()\n]+?))?(?:\s*\(verified\s+(\d{4}-\d{2}-\d{2})\))?\s*$/i;
|
|
3318
|
+
var DECISION_RECORD = /^\s*(?:#{1,6}\s*)?(?:\*\*)?(Context|Decision|Consequences|Alternatives|Status|Trade-?offs|Rationale)(?:\*\*)?\s*(?::|$)/im;
|
|
3319
|
+
var ARCHIVE_LANGUAGE = /\b(?:archive\s+of\s+record|this\s+(?:is|file\s+is|map\s+is)\s+the\s+only\s+(?:copy|record)|the\s+only\s+place\s+[^.\n]{0,80}?\b(?:remains|survives)\b|the\s+only\s+(?:copy|record)\s+[^.\n]{0,80}?\b(?:remains|survives|is\s+kept)\b|(?:survives|remains|is\s+kept)\s+only\s+here|no\s+longer\s+exists\s+(?:in|anywhere)|exists\s+only\s+(?:in|here))\b/i;
|
|
3320
|
+
var isCalendarDate2 = (value) => {
|
|
3321
|
+
const at = Date.parse(`${value}T00:00:00Z`);
|
|
3322
|
+
return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
|
|
3323
|
+
};
|
|
3324
|
+
var normalizeHeading = (value) => value.toLowerCase().replace(/[`*_]/g, "").replace(/^\s*\d+(?:\.\d+)*[.)]?\s*/, "").replace(/[^a-z0-9]+/g, " ").trim();
|
|
3325
|
+
var normalizeRule = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
3326
|
+
var truncate = (value, max = 90) => value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
|
|
3327
|
+
var parseMapEntries = (text) => {
|
|
3328
|
+
const lines = text.split(/\r?\n/);
|
|
3329
|
+
const entries = [];
|
|
3330
|
+
let inFence = false;
|
|
3331
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3332
|
+
if (FENCE.test(lines[i])) {
|
|
3333
|
+
inFence = !inFence;
|
|
3334
|
+
continue;
|
|
3335
|
+
}
|
|
3336
|
+
if (inFence) continue;
|
|
3337
|
+
const start = ENTRY_START.exec(lines[i]);
|
|
3338
|
+
if (!start) continue;
|
|
3339
|
+
const bodyLines = [start[3]];
|
|
3340
|
+
let j = i + 1;
|
|
3341
|
+
while (j < lines.length && CONTINUATION.test(lines[j]) && !FENCE.test(lines[j])) {
|
|
3342
|
+
bodyLines.push(lines[j].trim());
|
|
3343
|
+
j++;
|
|
3344
|
+
}
|
|
3345
|
+
const body = bodyLines.join("\n");
|
|
3346
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
3347
|
+
const pointerMatch = POINTER.exec(flat);
|
|
3348
|
+
const verifiedMatch = VERIFIED.exec(flat);
|
|
3349
|
+
const pointer = pointerMatch?.[1]?.trim();
|
|
3350
|
+
const section = pointerMatch?.[2]?.trim() || void 0;
|
|
3351
|
+
const verified = pointerMatch?.[3] ?? verifiedMatch?.[1];
|
|
3352
|
+
const rule = flat.replace(POINTER, "").replace(VERIFIED, "").trim();
|
|
3353
|
+
entries.push({
|
|
3354
|
+
startLine: i,
|
|
3355
|
+
endLine: j,
|
|
3356
|
+
marker: start[2].toLowerCase(),
|
|
3357
|
+
body,
|
|
3358
|
+
rule,
|
|
3359
|
+
lineCount: bodyLines.filter((line) => line.trim()).length,
|
|
3360
|
+
...pointer ? { pointer } : {},
|
|
3361
|
+
...section ? { section } : {},
|
|
3362
|
+
...verified ? { verified } : {}
|
|
3363
|
+
});
|
|
3364
|
+
i = j - 1;
|
|
3365
|
+
}
|
|
3366
|
+
return entries;
|
|
3367
|
+
};
|
|
3368
|
+
var stripLineSuffix = (pointer) => pointer.replace(/[\\/]$/, "").replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/i, "");
|
|
3369
|
+
var headingOf = (line) => {
|
|
3370
|
+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
3371
|
+
if (!match) return void 0;
|
|
3372
|
+
return { level: match[1].length, text: match[2] };
|
|
3373
|
+
};
|
|
3374
|
+
var sectionText = (text, section) => {
|
|
3375
|
+
const lines = text.split(/\r?\n/);
|
|
3376
|
+
const wanted = normalizeHeading(section);
|
|
3377
|
+
if (!wanted) return void 0;
|
|
3378
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3379
|
+
const heading = headingOf(lines[i]);
|
|
3380
|
+
if (!heading) continue;
|
|
3381
|
+
const normalized = normalizeHeading(heading.text);
|
|
3382
|
+
if (!(normalized === wanted || normalized.includes(wanted) || wanted.includes(normalized))) continue;
|
|
3383
|
+
let end = lines.length;
|
|
3384
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
3385
|
+
const next = headingOf(lines[j]);
|
|
3386
|
+
if (next && next.level <= heading.level) {
|
|
3387
|
+
end = j;
|
|
3388
|
+
break;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
return lines.slice(i + 1, end).join("\n");
|
|
3392
|
+
}
|
|
3393
|
+
return void 0;
|
|
3394
|
+
};
|
|
3395
|
+
var resolvePointer = (root, pointer, section) => {
|
|
3396
|
+
const cleaned = stripLineSuffix(pointer.trim().replace(/^`|`$/g, ""));
|
|
3397
|
+
if (!cleaned) return { exists: false };
|
|
3398
|
+
const absolute = path.resolve(root, cleaned);
|
|
3399
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
3400
|
+
if (absolute !== root && !absolute.startsWith(rootWithSep)) return { exists: false };
|
|
3401
|
+
let stat;
|
|
3402
|
+
try {
|
|
3403
|
+
stat = statSync3(absolute);
|
|
3404
|
+
} catch {
|
|
3405
|
+
return { exists: false };
|
|
3406
|
+
}
|
|
3407
|
+
if (!stat.isFile()) return { exists: false };
|
|
3408
|
+
let text;
|
|
3409
|
+
try {
|
|
3410
|
+
if (stat.size <= 512 * 1024) text = readFileSync4(absolute, "utf8");
|
|
3411
|
+
} catch {
|
|
3412
|
+
text = void 0;
|
|
3413
|
+
}
|
|
3414
|
+
if (section && text !== void 0) {
|
|
3415
|
+
const found = sectionText(text, section);
|
|
3416
|
+
return {
|
|
3417
|
+
absolute,
|
|
3418
|
+
exists: true,
|
|
3419
|
+
mtimeMs: stat.mtimeMs,
|
|
3420
|
+
sectionFound: found !== void 0,
|
|
3421
|
+
...found !== void 0 ? { sectionText: found } : {},
|
|
3422
|
+
sourceText: found ?? text
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
return { absolute, exists: true, mtimeMs: stat.mtimeMs, ...text !== void 0 ? { sourceText: text } : {} };
|
|
3426
|
+
};
|
|
3427
|
+
var words = (value) => value.toLowerCase().replace(/[`*_[\]()]/g, " ").split(/[^a-z0-9]+/).filter((word) => word.length > 0);
|
|
3428
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
3429
|
+
"the",
|
|
3430
|
+
"and",
|
|
3431
|
+
"for",
|
|
3432
|
+
"with",
|
|
3433
|
+
"that",
|
|
3434
|
+
"this",
|
|
3435
|
+
"from",
|
|
3436
|
+
"are",
|
|
3437
|
+
"was",
|
|
3438
|
+
"were",
|
|
3439
|
+
"has",
|
|
3440
|
+
"have",
|
|
3441
|
+
"had",
|
|
3442
|
+
"not",
|
|
3443
|
+
"never",
|
|
3444
|
+
"none",
|
|
3445
|
+
"into",
|
|
3446
|
+
"onto",
|
|
3447
|
+
"than",
|
|
3448
|
+
"then",
|
|
3449
|
+
"when",
|
|
3450
|
+
"where",
|
|
3451
|
+
"which",
|
|
3452
|
+
"what",
|
|
3453
|
+
"who",
|
|
3454
|
+
"will",
|
|
3455
|
+
"would",
|
|
3456
|
+
"can",
|
|
3457
|
+
"could",
|
|
3458
|
+
"must",
|
|
3459
|
+
"may",
|
|
3460
|
+
"might",
|
|
3461
|
+
"its",
|
|
3462
|
+
"their",
|
|
3463
|
+
"your",
|
|
3464
|
+
"our",
|
|
3465
|
+
"you",
|
|
3466
|
+
"they",
|
|
3467
|
+
"them",
|
|
3468
|
+
"there",
|
|
3469
|
+
"here",
|
|
3470
|
+
"only",
|
|
3471
|
+
"also",
|
|
3472
|
+
"but",
|
|
3473
|
+
"all",
|
|
3474
|
+
"any",
|
|
3475
|
+
"each",
|
|
3476
|
+
"every",
|
|
3477
|
+
"more",
|
|
3478
|
+
"most",
|
|
3479
|
+
"some",
|
|
3480
|
+
"such",
|
|
3481
|
+
"one",
|
|
3482
|
+
"two",
|
|
3483
|
+
"use",
|
|
3484
|
+
"used",
|
|
3485
|
+
"using",
|
|
3486
|
+
"via",
|
|
3487
|
+
"per",
|
|
3488
|
+
"out",
|
|
3489
|
+
"own"
|
|
3490
|
+
]);
|
|
3491
|
+
var contentWords = (value) => words(value).filter((word) => word.length >= 4 && !STOPWORDS.has(word));
|
|
3492
|
+
var ngrams = (values, size) => {
|
|
3493
|
+
const out = /* @__PURE__ */ new Set();
|
|
3494
|
+
for (let i = 0; i + size <= values.length; i++) out.add(values.slice(i, i + size).join(" "));
|
|
3495
|
+
return out;
|
|
3496
|
+
};
|
|
3497
|
+
var hasNegation = (value) => /\b(?:not|never|none|no\s+longer|refuted|unsupported|absent|missing|removed|without)\b/i.test(value);
|
|
3498
|
+
var STATE_AUXILIARY = /\b(?:is|are|was|were|has|have|had|does|do|can|could)\b/i;
|
|
3499
|
+
var numbersIn = (value) => (value.match(/\b\d+(?:[.,]\d+)?\b/g) ?? []).map((number) => number.replace(/,/g, ""));
|
|
3500
|
+
var sharesVerbatimRun = (rule, source) => {
|
|
3501
|
+
const entryWords = words(rule);
|
|
3502
|
+
if (entryWords.length < 12) return false;
|
|
3503
|
+
const sourceGrams = ngrams(words(source), 12);
|
|
3504
|
+
for (const gram of ngrams(entryWords, 12)) if (sourceGrams.has(gram)) return true;
|
|
3505
|
+
return false;
|
|
3506
|
+
};
|
|
3507
|
+
var possibleNegationConflict = (rule, source) => {
|
|
3508
|
+
if (hasNegation(rule)) return void 0;
|
|
3509
|
+
const entryWords = new Set(contentWords(rule));
|
|
3510
|
+
if (entryWords.size < 3) return void 0;
|
|
3511
|
+
const sentences = source.split(/(?:[.!?]\s+|\n+)/);
|
|
3512
|
+
for (const sentence of sentences) {
|
|
3513
|
+
if (!hasNegation(sentence) || !STATE_AUXILIARY.test(sentence)) continue;
|
|
3514
|
+
const sentenceWords = new Set(contentWords(sentence));
|
|
3515
|
+
if (sentenceWords.size === 0) continue;
|
|
3516
|
+
let overlap = 0;
|
|
3517
|
+
for (const word of entryWords) if (sentenceWords.has(word)) overlap++;
|
|
3518
|
+
const ratio = overlap / Math.min(entryWords.size, sentenceWords.size);
|
|
3519
|
+
if (overlap >= 3 && ratio >= 0.5) return truncate(sentence.trim());
|
|
3520
|
+
}
|
|
3521
|
+
return void 0;
|
|
3522
|
+
};
|
|
3523
|
+
var invalidNumbers = (rule, source) => {
|
|
3524
|
+
const claimed = [...new Set(numbersIn(rule))];
|
|
3525
|
+
if (claimed.length === 0) return [];
|
|
3526
|
+
const sourceNumbers = new Set(numbersIn(source));
|
|
3527
|
+
return claimed.filter((number) => !sourceNumbers.has(number));
|
|
3528
|
+
};
|
|
3529
|
+
var validateMapText = (spec, text, options) => {
|
|
3530
|
+
const violations = [];
|
|
3531
|
+
const lines = text.split(/\r?\n/);
|
|
3532
|
+
const nonBlank = lines.filter((line) => line.trim()).length;
|
|
3533
|
+
const push = (kind, detail, line) => violations.push({ file: spec.file, kind, detail, ...line !== void 0 ? { line: line + 1 } : {} });
|
|
3534
|
+
if (nonBlank === 0) push("charter", `empty file; charter is ${spec.charter}`);
|
|
3535
|
+
if (nonBlank > spec.budget) {
|
|
3536
|
+
push("budget", `${nonBlank} non-blank lines exceeds the ${spec.budget}-line budget; consolidate, do not pad`);
|
|
3537
|
+
}
|
|
3538
|
+
if (spec.kind === "backlog") {
|
|
3539
|
+
if (!text.startsWith("# ")) push("charter", "active backlog must start with a top-level heading");
|
|
3540
|
+
return { spec, lines: nonBlank, entries: [], violations };
|
|
3541
|
+
}
|
|
3542
|
+
const entries = parseMapEntries(text);
|
|
3543
|
+
let inFence = false;
|
|
3544
|
+
let tableReported = false;
|
|
3545
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3546
|
+
if (FENCE.test(lines[i])) {
|
|
3547
|
+
if (!inFence) push("restatement", "fenced code block; the map cites the file that owns the code, it does not copy it", i);
|
|
3548
|
+
inFence = !inFence;
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3551
|
+
if (!inFence && !tableReported && (TABLE_ROW.test(lines[i]) || TABLE_SEPARATOR.test(lines[i]))) {
|
|
3552
|
+
push("restatement", "table; a table belongs on the page that owns it, not in the map", i);
|
|
3553
|
+
tableReported = true;
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
const record = DECISION_RECORD.exec(text);
|
|
3557
|
+
if (record) {
|
|
3558
|
+
push("schema", `decision-record block (${record[1]}); state only the operative constraint and its pointer`);
|
|
3559
|
+
}
|
|
3560
|
+
if (ARCHIVE_LANGUAGE.test(text)) {
|
|
3561
|
+
push("archive", "the map claims to be an archive or the only copy; derived content must never be the archive of record");
|
|
3562
|
+
}
|
|
3563
|
+
const entryStarts = new Set(entries.map((entry) => entry.startLine));
|
|
3564
|
+
inFence = false;
|
|
3565
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3566
|
+
if (FENCE.test(lines[i])) {
|
|
3567
|
+
inFence = !inFence;
|
|
3568
|
+
continue;
|
|
3569
|
+
}
|
|
3570
|
+
if (!inFence && LIST_ITEM.test(lines[i]) && !entryStarts.has(i)) {
|
|
3571
|
+
push("schema", "unmarked list item; every entry starts with - [binding], - [provisional] or - [disputed]", i);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
let binding = 0;
|
|
3575
|
+
for (const entry of entries) {
|
|
3576
|
+
const line = entry.startLine;
|
|
3577
|
+
if (entry.marker === "binding") binding++;
|
|
3578
|
+
if (entry.lineCount > 3) {
|
|
3579
|
+
push("schema", `entry is ${entry.lineCount} lines; the rule must fit in one to three`, line);
|
|
3580
|
+
}
|
|
3581
|
+
if (!entry.verified) {
|
|
3582
|
+
push("schema", "entry has no (verified YYYY-MM-DD); the lifecycle and drift checks need it", line);
|
|
3583
|
+
} else if (!isCalendarDate2(entry.verified)) {
|
|
3584
|
+
push("schema", `verified date ${entry.verified} is not a real calendar date`, line);
|
|
3585
|
+
} else if (Date.parse(`${entry.verified}T00:00:00Z`) > (options.now ?? Date.now()) + DAY_MS2) {
|
|
3586
|
+
push("schema", `verified date ${entry.verified} is more than a day in the future`, line);
|
|
3587
|
+
}
|
|
3588
|
+
if (!entry.pointer) {
|
|
3589
|
+
push("schema", "entry has no `\u2014 See <path>` pointer; a fact with no home is not a map entry", line);
|
|
3590
|
+
push("unarchived", "content has no resolvable source in the tree; report it, do not emit it as derived", line);
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
if (spec.sectionRequired && /\.md$/i.test(stripLineSuffix(entry.pointer)) && !entry.section) {
|
|
3594
|
+
push("schema", `page citation \`${entry.pointer}\` needs a \xA7section; a page is cited, not restated`, line);
|
|
3595
|
+
}
|
|
3596
|
+
const resolved = resolvePointer(options.root, entry.pointer, entry.section);
|
|
3597
|
+
if (!resolved.exists) {
|
|
3598
|
+
push("dangling", `cites \`${entry.pointer}\`, which does not exist in the tree`, line);
|
|
3599
|
+
push("unarchived", `no source for \`${entry.pointer}\`; keep the text and report it, never delete it`, line);
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (entry.section && resolved.sectionFound === false) {
|
|
3603
|
+
push("drift", `cites \xA7${entry.section} in \`${entry.pointer}\`, but that section no longer exists`, line);
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
if (resolved.mtimeMs !== void 0 && entry.verified) {
|
|
3607
|
+
const modified = new Date(resolved.mtimeMs).toISOString().slice(0, 10);
|
|
3608
|
+
if (modified > entry.verified) {
|
|
3609
|
+
push("drift", `\`${entry.pointer}\` changed on ${modified}, after this entry was verified on ${entry.verified}; re-verify it`, line);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
if (resolved.sourceText !== void 0) {
|
|
3613
|
+
const missing = invalidNumbers(entry.rule, resolved.sourceText);
|
|
3614
|
+
if (missing.length > 0) {
|
|
3615
|
+
push("drift", `numeric claim(s) ${missing.join(", ")} not found in \`${entry.pointer}\`; the map and the source may disagree`, line);
|
|
3616
|
+
}
|
|
3617
|
+
if (sharesVerbatimRun(entry.rule, resolved.sourceText)) {
|
|
3618
|
+
push("restatement", `contains a long verbatim span from \`${entry.pointer}\`; cite the section instead`, line);
|
|
3619
|
+
}
|
|
3620
|
+
const conflict = possibleNegationConflict(entry.rule, resolved.sourceText);
|
|
3621
|
+
if (conflict) {
|
|
3622
|
+
push("drift", `possible contradiction with \`${entry.pointer}\`: "${conflict}"; verify, do not silently overwrite either side`, line);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
if (entries.length < spec.minEntries) {
|
|
3627
|
+
push("charter", `${entries.length} entr${entries.length === 1 ? "y" : "ies"} is degenerate for ${spec.charter}`);
|
|
3628
|
+
} else if (entries.length > 0 && binding === 0) {
|
|
3629
|
+
push("charter", "every entry is provisional or disputed; a navigation file must carry at least one binding constraint");
|
|
3630
|
+
}
|
|
3631
|
+
return { spec, lines: nonBlank, entries, violations };
|
|
3632
|
+
};
|
|
3633
|
+
var normalizeMapText = (spec, text, options = {}) => {
|
|
3634
|
+
const now = options.now ?? Date.now();
|
|
3635
|
+
if (spec.kind === "backlog") {
|
|
3636
|
+
const result = normalizeBacklog(text, now);
|
|
3637
|
+
return {
|
|
3638
|
+
text: result.text,
|
|
3639
|
+
removed: result.removed.map((title) => ({ line: 0, text: title })),
|
|
3640
|
+
kept: 0
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
const entries = parseMapEntries(text);
|
|
3644
|
+
const drop = /* @__PURE__ */ new Set();
|
|
3645
|
+
const removed = [];
|
|
3646
|
+
for (const entry of entries) {
|
|
3647
|
+
if (entry.marker !== "provisional") continue;
|
|
3648
|
+
if (!entry.verified || !entry.pointer) continue;
|
|
3649
|
+
if (options.canResolve && !options.canResolve(entry.pointer, entry.section)) continue;
|
|
3650
|
+
const at = Date.parse(`${entry.verified}T00:00:00Z`);
|
|
3651
|
+
if (!Number.isFinite(at)) continue;
|
|
3652
|
+
if (now - at <= spec.provisionalDays * DAY_MS2) continue;
|
|
3653
|
+
for (let i = entry.startLine; i < entry.endLine; i++) drop.add(i);
|
|
3654
|
+
removed.push({ line: entry.startLine + 1, text: truncate(entry.rule) });
|
|
3655
|
+
}
|
|
3656
|
+
if (removed.length === 0) return { text, removed, kept: entries.length };
|
|
3657
|
+
const keptLines = text.split(/\r?\n/).filter((_, index) => !drop.has(index));
|
|
3658
|
+
const compact = keptLines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
3659
|
+
const out = compact.endsWith("\n") ? compact : `${compact}
|
|
3660
|
+
`;
|
|
3661
|
+
return { text: out, removed, kept: entries.length - removed.length };
|
|
3662
|
+
};
|
|
3663
|
+
var maintainMap = (root, mapDir, now = Date.now()) => {
|
|
3664
|
+
const files = [];
|
|
3665
|
+
let removedTotal = 0;
|
|
3666
|
+
for (const spec of MAP_SPECS) {
|
|
3667
|
+
const absolute = path.join(mapDir, spec.file);
|
|
3668
|
+
if (!existsSync2(absolute)) continue;
|
|
3669
|
+
let before;
|
|
3670
|
+
try {
|
|
3671
|
+
before = readFileSync4(absolute, "utf8");
|
|
3672
|
+
} catch {
|
|
3673
|
+
continue;
|
|
3674
|
+
}
|
|
3675
|
+
const canResolve = (pointer, section) => resolvePointer(root, pointer, section).exists;
|
|
3676
|
+
const result = normalizeMapText(spec, before, { now, canResolve });
|
|
3677
|
+
const changed = result.text !== before;
|
|
3678
|
+
if (changed) writeFileSync3(absolute, result.text);
|
|
3679
|
+
if (result.removed.length > 0) {
|
|
3680
|
+
removedTotal += result.removed.length;
|
|
3681
|
+
files.push({ file: spec.file, changed, removed: result.removed });
|
|
3682
|
+
} else if (changed) {
|
|
3683
|
+
files.push({ file: spec.file, changed, removed: [] });
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
return { files, removedTotal };
|
|
3687
|
+
};
|
|
3688
|
+
var auditMap = (root, mapDir, now = Date.now()) => {
|
|
3689
|
+
const files = [];
|
|
3690
|
+
const violations = [];
|
|
3691
|
+
const rulesSeen = /* @__PURE__ */ new Map();
|
|
3692
|
+
for (const spec of MAP_SPECS) {
|
|
3693
|
+
const absolute = path.join(mapDir, spec.file);
|
|
3694
|
+
if (!existsSync2(absolute)) {
|
|
3695
|
+
const violation = { file: spec.file, kind: "missing-file", detail: `missing; charter is ${spec.charter}` };
|
|
3696
|
+
violations.push(violation);
|
|
3697
|
+
continue;
|
|
3698
|
+
}
|
|
3699
|
+
let text;
|
|
3700
|
+
try {
|
|
3701
|
+
text = readFileSync4(absolute, "utf8");
|
|
3702
|
+
} catch {
|
|
3703
|
+
violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
const result = validateMapText(spec, text, { root, now });
|
|
3707
|
+
files.push(result);
|
|
3708
|
+
violations.push(...result.violations);
|
|
3709
|
+
if (spec.kind === "entries") {
|
|
3710
|
+
for (const entry of result.entries) {
|
|
3711
|
+
const key = normalizeRule(entry.rule);
|
|
3712
|
+
if (key.length < 12) continue;
|
|
3713
|
+
const home = rulesSeen.get(key);
|
|
3714
|
+
if (home !== void 0 && home !== spec.file) {
|
|
3715
|
+
violations.push({
|
|
3716
|
+
file: spec.file,
|
|
3717
|
+
line: entry.startLine + 1,
|
|
3718
|
+
kind: "duplicate",
|
|
3719
|
+
detail: `same rule is already stated in ${home}; one fact has one home`
|
|
3720
|
+
});
|
|
3721
|
+
} else if (home === void 0) {
|
|
3722
|
+
rulesSeen.set(key, spec.file);
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
2926
3726
|
}
|
|
2927
|
-
return {
|
|
3727
|
+
return { ok: violations.length === 0, files, violations };
|
|
2928
3728
|
};
|
|
3729
|
+
var formatViolations = (violations, limit = 20) => violations.slice(0, limit).map((violation) => {
|
|
3730
|
+
const where = violation.line !== void 0 ? `${violation.file}:${violation.line}` : violation.file;
|
|
3731
|
+
return `${where} [${violation.kind}] ${violation.detail}`;
|
|
3732
|
+
});
|
|
2929
3733
|
|
|
2930
3734
|
// src/lib/jsonc.ts
|
|
2931
3735
|
var stripJsonc = (raw) => {
|
|
@@ -2975,12 +3779,12 @@ var parseJsonc = (raw) => {
|
|
|
2975
3779
|
};
|
|
2976
3780
|
|
|
2977
3781
|
// src/lib/writer-rule.ts
|
|
2978
|
-
import { readFileSync as
|
|
2979
|
-
import * as
|
|
2980
|
-
var CONTEXT_DIR =
|
|
3782
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
3783
|
+
import * as path2 from "node:path";
|
|
3784
|
+
var CONTEXT_DIR = path2.join(".opencode", "context");
|
|
2981
3785
|
var WRITER = "context";
|
|
2982
3786
|
var INSTRUCTIONS_WRITER = AGENT_INSTRUCTIONS_WRITER;
|
|
2983
|
-
var toPosix = (value) => value.split(
|
|
3787
|
+
var toPosix = (value) => value.split(path2.sep).join("/");
|
|
2984
3788
|
var compilePattern = (pattern) => {
|
|
2985
3789
|
const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
2986
3790
|
return new RegExp(`^${escaped}$`, "s");
|
|
@@ -3005,17 +3809,17 @@ var readAgents = (raw) => {
|
|
|
3005
3809
|
};
|
|
3006
3810
|
var permissionOf = (agent) => agent?.permission?.edit;
|
|
3007
3811
|
var evaluatedForm = (mapDir, projectRoot) => {
|
|
3008
|
-
const absolute = toPosix(
|
|
3009
|
-
return projectRoot ? toPosix(
|
|
3812
|
+
const absolute = toPosix(path2.join(mapDir, "architecture.md"));
|
|
3813
|
+
return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
|
|
3010
3814
|
};
|
|
3011
3815
|
var agentsForm = (mapDir, projectRoot) => {
|
|
3012
|
-
const absolute = toPosix(
|
|
3013
|
-
return projectRoot ? toPosix(
|
|
3816
|
+
const absolute = toPosix(path2.join(mapDir, "..", "..", "AGENTS.md"));
|
|
3817
|
+
return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
|
|
3014
3818
|
};
|
|
3015
3819
|
var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
3016
3820
|
let raw;
|
|
3017
3821
|
try {
|
|
3018
|
-
raw =
|
|
3822
|
+
raw = readFileSync5(configPath, "utf8");
|
|
3019
3823
|
} catch {
|
|
3020
3824
|
return void 0;
|
|
3021
3825
|
}
|
|
@@ -3028,7 +3832,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
|
3028
3832
|
const form = evaluatedForm(mapDir, projectRoot);
|
|
3029
3833
|
if (rules.length > 0) {
|
|
3030
3834
|
if (!rules.some((rule) => compilePattern(rule).test(form))) {
|
|
3031
|
-
return `context map is not writable: none of the allow rules in ${
|
|
3835
|
+
return `context map is not writable: none of the allow rules in ${path2.basename(configPath)} match ` + (projectRoot ? `the project-root-relative form "${form}" that opencode actually evaluates for project root ${toPosix(projectRoot)}` : `the absolute form "${form}"`) + `. Rules seen: ${rules.map((r) => `"${r}"`).join(", ")}. opencode evaluates file permissions against the path RELATIVE TO THE PROJECT ROOT, so "${CONTEXT_DIR}/**" is what fires for a normally-rooted project while "*/${CONTEXT_DIR}/**" only fires when the project root is "/". Allow both forms.`;
|
|
3032
3836
|
}
|
|
3033
3837
|
}
|
|
3034
3838
|
const agents = readAgents(raw);
|
|
@@ -3061,7 +3865,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
|
3061
3865
|
|
|
3062
3866
|
// src/plugins/context-autoupdate.ts
|
|
3063
3867
|
var SERVICE2 = "context-autoupdate";
|
|
3064
|
-
var CONTEXT_DIR2 =
|
|
3868
|
+
var CONTEXT_DIR2 = path3.join(".opencode", "context");
|
|
3065
3869
|
var COOLDOWN_MS = 10 * 60 * 1e3;
|
|
3066
3870
|
var OWNED_TTL_MS = 20 * 60 * 1e3;
|
|
3067
3871
|
var MAX_FILES_LISTED = 25;
|
|
@@ -3098,13 +3902,19 @@ var NO_BULK_READS = [
|
|
|
3098
3902
|
"that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
|
|
3099
3903
|
"`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
|
|
3100
3904
|
].join(" ");
|
|
3101
|
-
var
|
|
3905
|
+
var ENTRY_CONTRACT = [
|
|
3906
|
+
"Every navigation entry uses the schema in your system prompt:",
|
|
3907
|
+
"`- [binding|provisional|disputed] <rule, at most three lines> \u2014 See `<path>`[ \xA7Section] (verified YYYY-MM-DD)`.",
|
|
3908
|
+
"Stay inside each file's line budget. Point at the page or path that owns a fact; do not restate it.",
|
|
3909
|
+
"After writing, run the drift pass: report a contradiction, never overwrite the page or silently pick a side."
|
|
3910
|
+
].join(" ");
|
|
3911
|
+
var toPosix2 = (value) => value.split(path3.sep).join("/");
|
|
3102
3912
|
var findProjectConfig = () => {
|
|
3103
|
-
let dir = import.meta.dir ??
|
|
3913
|
+
let dir = import.meta.dir ?? path3.dirname(new URL(import.meta.url).pathname);
|
|
3104
3914
|
for (let up = 0; up <= 3; up++) {
|
|
3105
|
-
const candidate =
|
|
3106
|
-
if (
|
|
3107
|
-
const parent =
|
|
3915
|
+
const candidate = path3.join(dir, "opencode.jsonc");
|
|
3916
|
+
if (existsSync3(candidate)) return candidate;
|
|
3917
|
+
const parent = path3.dirname(dir);
|
|
3108
3918
|
if (parent === dir) break;
|
|
3109
3919
|
dir = parent;
|
|
3110
3920
|
}
|
|
@@ -3114,9 +3924,9 @@ var snapshotMap = (dir) => {
|
|
|
3114
3924
|
const out = /* @__PURE__ */ new Map();
|
|
3115
3925
|
try {
|
|
3116
3926
|
for (const name of readdirSync2(dir)) {
|
|
3117
|
-
const file =
|
|
3927
|
+
const file = path3.join(dir, name);
|
|
3118
3928
|
try {
|
|
3119
|
-
const st =
|
|
3929
|
+
const st = statSync4(file);
|
|
3120
3930
|
if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
|
|
3121
3931
|
} catch {
|
|
3122
3932
|
}
|
|
@@ -3133,20 +3943,20 @@ var changedSince = (before, dir) => {
|
|
|
3133
3943
|
return changed;
|
|
3134
3944
|
};
|
|
3135
3945
|
var RETAIN_TRANSCRIPTS = 20;
|
|
3136
|
-
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ??
|
|
3946
|
+
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
3137
3947
|
var retireSession = async (client, directory, sessionID, mode, log) => {
|
|
3138
3948
|
try {
|
|
3139
3949
|
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
3140
3950
|
const dir = transcriptDir();
|
|
3141
3951
|
mkdirSync3(dir, { recursive: true });
|
|
3142
3952
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3143
|
-
const file =
|
|
3144
|
-
|
|
3953
|
+
const file = path3.join(dir, `${stamp}-${mode}.json`);
|
|
3954
|
+
writeFileSync4(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
|
|
3145
3955
|
pruneTranscripts(dir);
|
|
3146
3956
|
const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
|
|
3147
3957
|
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
3148
3958
|
if (await gone()) return;
|
|
3149
|
-
await new Promise((
|
|
3959
|
+
await new Promise((resolve5) => setTimeout(resolve5, 500));
|
|
3150
3960
|
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
3151
3961
|
if (await gone()) return;
|
|
3152
3962
|
await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
|
|
@@ -3162,7 +3972,7 @@ var pruneTranscripts = (dir) => {
|
|
|
3162
3972
|
const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
|
|
3163
3973
|
for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
|
|
3164
3974
|
try {
|
|
3165
|
-
unlinkSync3(
|
|
3975
|
+
unlinkSync3(path3.join(dir, name));
|
|
3166
3976
|
} catch {
|
|
3167
3977
|
}
|
|
3168
3978
|
}
|
|
@@ -3176,24 +3986,24 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3176
3986
|
const state2 = state(options);
|
|
3177
3987
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
|
|
3178
3988
|
});
|
|
3179
|
-
const root =
|
|
3180
|
-
const contextAbs =
|
|
3181
|
-
const activeBacklogPath =
|
|
3989
|
+
const root = path3.resolve(directory);
|
|
3990
|
+
const contextAbs = path3.join(root, CONTEXT_DIR2);
|
|
3991
|
+
const activeBacklogPath = path3.join(contextAbs, ACTIVE_BACKLOG_FILE);
|
|
3182
3992
|
const config = findProjectConfig();
|
|
3183
3993
|
const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
|
|
3184
3994
|
if (writerProblem) void log("error", writerProblem);
|
|
3185
3995
|
const isTracked = (file) => {
|
|
3186
|
-
const absolute =
|
|
3187
|
-
if (absolute !== root && !absolute.startsWith(root +
|
|
3188
|
-
if (absolute === contextAbs || absolute.startsWith(contextAbs +
|
|
3996
|
+
const absolute = path3.resolve(root, file);
|
|
3997
|
+
if (absolute !== root && !absolute.startsWith(root + path3.sep)) return false;
|
|
3998
|
+
if (absolute === contextAbs || absolute.startsWith(contextAbs + path3.sep)) return false;
|
|
3189
3999
|
return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
|
|
3190
4000
|
};
|
|
3191
4001
|
const normalizeActiveBacklog = async () => {
|
|
3192
4002
|
try {
|
|
3193
|
-
if (!
|
|
3194
|
-
const before =
|
|
4003
|
+
if (!existsSync3(activeBacklogPath)) return { removed: 0 };
|
|
4004
|
+
const before = readFileSync6(activeBacklogPath, "utf8");
|
|
3195
4005
|
const { text, removed } = normalizeBacklog(before);
|
|
3196
|
-
if (text !== before)
|
|
4006
|
+
if (text !== before) writeFileSync4(activeBacklogPath, text);
|
|
3197
4007
|
if (removed.length > 0) {
|
|
3198
4008
|
await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
|
|
3199
4009
|
removed: removed.length
|
|
@@ -3204,7 +4014,46 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3204
4014
|
return { removed: 0 };
|
|
3205
4015
|
}
|
|
3206
4016
|
};
|
|
3207
|
-
|
|
4017
|
+
const applyLifecycle = async () => {
|
|
4018
|
+
try {
|
|
4019
|
+
const result = maintainMap(root, contextAbs);
|
|
4020
|
+
for (const file of result.files) {
|
|
4021
|
+
if (file.removed.length === 0) continue;
|
|
4022
|
+
await log(
|
|
4023
|
+
"info",
|
|
4024
|
+
`map lifecycle: dropped ${file.removed.length} expired provisional entr${file.removed.length === 1 ? "y" : "ies"} from ${file.file}`,
|
|
4025
|
+
{ file: file.file, removed: file.removed.map((entry) => entry.text) }
|
|
4026
|
+
);
|
|
4027
|
+
}
|
|
4028
|
+
return result;
|
|
4029
|
+
} catch {
|
|
4030
|
+
return void 0;
|
|
4031
|
+
}
|
|
4032
|
+
};
|
|
4033
|
+
const reportMapAudit = async (mode) => {
|
|
4034
|
+
try {
|
|
4035
|
+
const audit = auditMap(root, contextAbs);
|
|
4036
|
+
if (audit.ok) {
|
|
4037
|
+
await log("info", "context map validation passed", { mode, files: audit.files.length });
|
|
4038
|
+
return audit;
|
|
4039
|
+
}
|
|
4040
|
+
const byKind = {};
|
|
4041
|
+
for (const violation of audit.violations) {
|
|
4042
|
+
byKind[violation.kind] = (byKind[violation.kind] ?? 0) + 1;
|
|
4043
|
+
}
|
|
4044
|
+
await log("error", `context map validation failed: ${audit.violations.length} issue(s)`, {
|
|
4045
|
+
mode,
|
|
4046
|
+
byKind,
|
|
4047
|
+
issues: formatViolations(audit.violations, 20),
|
|
4048
|
+
note: "reported, not rewritten; fix the entry or its source and run /context-update"
|
|
4049
|
+
});
|
|
4050
|
+
return audit;
|
|
4051
|
+
} catch (error) {
|
|
4052
|
+
await log("warn", `context map validation could not run: ${String(error)}`, { mode });
|
|
4053
|
+
return void 0;
|
|
4054
|
+
}
|
|
4055
|
+
};
|
|
4056
|
+
void applyLifecycle();
|
|
3208
4057
|
const listed = (files) => {
|
|
3209
4058
|
const shown = files.slice(0, MAX_FILES_LISTED);
|
|
3210
4059
|
const rest = files.length - shown.length;
|
|
@@ -3222,7 +4071,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3222
4071
|
"",
|
|
3223
4072
|
NO_BULK_READS,
|
|
3224
4073
|
"",
|
|
3225
|
-
|
|
4074
|
+
ENTRY_CONTRACT,
|
|
4075
|
+
"",
|
|
4076
|
+
"Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their line budgets. Also create `.opencode/context/active.md` with its heading and no tasks. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
|
|
3226
4077
|
].join("\n") : [
|
|
3227
4078
|
"Incremental context update. Project root: " + directory,
|
|
3228
4079
|
"",
|
|
@@ -3231,6 +4082,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3231
4082
|
"",
|
|
3232
4083
|
NO_BULK_READS,
|
|
3233
4084
|
"",
|
|
4085
|
+
ENTRY_CONTRACT,
|
|
4086
|
+
"",
|
|
3234
4087
|
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or an invariant not yet recorded. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
|
|
3235
4088
|
].join("\n");
|
|
3236
4089
|
return {
|
|
@@ -3238,7 +4091,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3238
4091
|
try {
|
|
3239
4092
|
if (event.type === "file.edited") {
|
|
3240
4093
|
const file = event.properties.file;
|
|
3241
|
-
if (
|
|
4094
|
+
if (path3.resolve(root, file) === activeBacklogPath) {
|
|
3242
4095
|
await normalizeActiveBacklog();
|
|
3243
4096
|
return;
|
|
3244
4097
|
}
|
|
@@ -3265,7 +4118,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3265
4118
|
const requeue = () => {
|
|
3266
4119
|
for (const file of files) pending.add(file);
|
|
3267
4120
|
};
|
|
3268
|
-
const mode =
|
|
4121
|
+
const mode = existsSync3(path3.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
|
|
3269
4122
|
init(state2, (m) => void log("warn", m));
|
|
3270
4123
|
const snap = snapshot(state2);
|
|
3271
4124
|
const slot = pickSlot(state2, snap);
|
|
@@ -3332,6 +4185,13 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3332
4185
|
hint: "either nothing needed updating, or the cartographer could not write -- check the one-writer permission rule"
|
|
3333
4186
|
});
|
|
3334
4187
|
}
|
|
4188
|
+
await applyLifecycle();
|
|
4189
|
+
const audit = await reportMapAudit(mode);
|
|
4190
|
+
if (audit && !audit.ok) {
|
|
4191
|
+
await log("error", `auto ${mode} finished with ${audit.violations.length} map validation issue(s)`, {
|
|
4192
|
+
sessionID: target
|
|
4193
|
+
});
|
|
4194
|
+
}
|
|
3335
4195
|
}).catch(async (error) => {
|
|
3336
4196
|
release(state2, claimKey2);
|
|
3337
4197
|
await retireSession(client, directory, target, mode, log);
|
|
@@ -3345,11 +4205,92 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3345
4205
|
};
|
|
3346
4206
|
});
|
|
3347
4207
|
|
|
4208
|
+
// src/lib/feedback.ts
|
|
4209
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
4210
|
+
import { homedir as homedir4 } from "node:os";
|
|
4211
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
4212
|
+
var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
|
|
4213
|
+
var FEEDBACK_HINT = "Enjoying lacode? Bugs, ideas, or a quick note on how you use it: github.com/lawaty/lacode/issues (a \u2B50 on the repo helps others find it)";
|
|
4214
|
+
var feedbackStatePath = () => join7(homedir4(), ".local", "share", "opencode", "lacode", "feedback.json");
|
|
4215
|
+
function readFeedbackState(path5 = feedbackStatePath()) {
|
|
4216
|
+
try {
|
|
4217
|
+
const parsed = JSON.parse(readFileSync7(path5, "utf8"));
|
|
4218
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
4219
|
+
return parsed;
|
|
4220
|
+
} catch (error) {
|
|
4221
|
+
if (error?.code === "ENOENT") return {};
|
|
4222
|
+
return void 0;
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
function markFeedbackHintShown(path5 = feedbackStatePath()) {
|
|
4226
|
+
try {
|
|
4227
|
+
mkdirSync4(dirname4(path5), { recursive: true });
|
|
4228
|
+
const tmp = `${path5}.${process.pid}.tmp`;
|
|
4229
|
+
writeFileSync5(tmp, JSON.stringify({ feedbackHintShown: true }));
|
|
4230
|
+
renameSync3(tmp, path5);
|
|
4231
|
+
return true;
|
|
4232
|
+
} catch {
|
|
4233
|
+
return false;
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
function restoreFeedbackState(path5 = feedbackStatePath(), previous = {}) {
|
|
4237
|
+
try {
|
|
4238
|
+
if (Object.keys(previous).length === 0) {
|
|
4239
|
+
unlinkSync4(path5);
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
const tmp = `${path5}.${process.pid}.tmp`;
|
|
4243
|
+
writeFileSync5(tmp, JSON.stringify(previous));
|
|
4244
|
+
renameSync3(tmp, path5);
|
|
4245
|
+
} catch {
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
async function maybeShowFeedbackHint(deps = {}) {
|
|
4249
|
+
const env = deps.env ?? process.env;
|
|
4250
|
+
if (deps.enabled === false || deps.feedbackHint === false) return "disabled";
|
|
4251
|
+
if (env[FEEDBACK_HINT_ENV] === "1") return "disabled";
|
|
4252
|
+
const interactive = deps.interactive ?? process.stdout.isTTY === true;
|
|
4253
|
+
if (!interactive || env.CI || env.NODE_TEST_CONTEXT) return "unavailable";
|
|
4254
|
+
if (!deps.show) return "unavailable";
|
|
4255
|
+
const state2 = readFeedbackState(deps.statePath);
|
|
4256
|
+
if (state2 === void 0 || state2.feedbackHintShown === true) return "skipped";
|
|
4257
|
+
if (!markFeedbackHintShown(deps.statePath)) return "skipped";
|
|
4258
|
+
try {
|
|
4259
|
+
await deps.show(FEEDBACK_HINT);
|
|
4260
|
+
} catch {
|
|
4261
|
+
restoreFeedbackState(deps.statePath, state2);
|
|
4262
|
+
return "skipped";
|
|
4263
|
+
}
|
|
4264
|
+
return "shown";
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
// src/plugins/feedback.ts
|
|
4268
|
+
async function feedbackHooks(input, options, deps = {}) {
|
|
4269
|
+
const opts = options ?? {};
|
|
4270
|
+
if (opts.enabled === false || opts.feedbackHint === false) return {};
|
|
4271
|
+
const tui = input.client.tui;
|
|
4272
|
+
const show = deps.show ?? (tui?.showToast ? async (message) => {
|
|
4273
|
+
await tui.showToast({
|
|
4274
|
+
body: { title: "lacode", message, variant: "info", duration: 8e3 }
|
|
4275
|
+
});
|
|
4276
|
+
} : void 0);
|
|
4277
|
+
void maybeShowFeedbackHint({
|
|
4278
|
+
enabled: opts.enabled,
|
|
4279
|
+
feedbackHint: opts.feedbackHint,
|
|
4280
|
+
env: deps.env,
|
|
4281
|
+
statePath: deps.statePath,
|
|
4282
|
+
interactive: deps.interactive,
|
|
4283
|
+
show
|
|
4284
|
+
}).catch(() => {
|
|
4285
|
+
});
|
|
4286
|
+
return {};
|
|
4287
|
+
}
|
|
4288
|
+
|
|
3348
4289
|
// src/plugins/image-paste.ts
|
|
3349
4290
|
import { createHash, randomUUID } from "node:crypto";
|
|
3350
|
-
import { globSync, mkdirSync as
|
|
3351
|
-
import { homedir as
|
|
3352
|
-
import { basename as basename2, join as
|
|
4291
|
+
import { globSync, mkdirSync as mkdirSync5, readFileSync as readFileSync8, renameSync as renameSync4, statSync as statSync5, writeFileSync as writeFileSync6 } from "node:fs";
|
|
4292
|
+
import { homedir as homedir5 } from "node:os";
|
|
4293
|
+
import { basename as basename2, join as join8 } from "node:path";
|
|
3353
4294
|
|
|
3354
4295
|
// src/lib/clipboard.ts
|
|
3355
4296
|
import { spawnSync } from "node:child_process";
|
|
@@ -3410,12 +4351,12 @@ var WINDOWS_SCRIPT = [
|
|
|
3410
4351
|
var readWindows = async (run) => {
|
|
3411
4352
|
const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
|
|
3412
4353
|
if (!result.ok) return null;
|
|
3413
|
-
const
|
|
3414
|
-
if (!
|
|
4354
|
+
const path5 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
4355
|
+
if (!path5) return null;
|
|
3415
4356
|
try {
|
|
3416
|
-
return validImage({ ok: true, stdout: await readFile(
|
|
4357
|
+
return validImage({ ok: true, stdout: await readFile(path5) });
|
|
3417
4358
|
} finally {
|
|
3418
|
-
await rm(
|
|
4359
|
+
await rm(path5, { force: true }).catch(() => {
|
|
3419
4360
|
});
|
|
3420
4361
|
}
|
|
3421
4362
|
};
|
|
@@ -3499,8 +4440,8 @@ function splitArguments(input) {
|
|
|
3499
4440
|
return out;
|
|
3500
4441
|
}
|
|
3501
4442
|
function expandTilde(p) {
|
|
3502
|
-
if (p === "~") return
|
|
3503
|
-
if (p.startsWith("~/")) return
|
|
4443
|
+
if (p === "~") return homedir5();
|
|
4444
|
+
if (p.startsWith("~/")) return join8(homedir5(), p.slice(2));
|
|
3504
4445
|
return p;
|
|
3505
4446
|
}
|
|
3506
4447
|
function expandGlob(p) {
|
|
@@ -3515,11 +4456,11 @@ function storeImage(bytes, dir, sessionID, label) {
|
|
|
3515
4456
|
const kind = detectImageKind(bytes);
|
|
3516
4457
|
if (kind === null) return null;
|
|
3517
4458
|
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
3518
|
-
|
|
3519
|
-
const target =
|
|
4459
|
+
mkdirSync5(dir, { recursive: true });
|
|
4460
|
+
const target = join8(dir, `${hash}.${kind.ext}`);
|
|
3520
4461
|
const tmp = `${target}.tmp`;
|
|
3521
|
-
|
|
3522
|
-
|
|
4462
|
+
writeFileSync6(tmp, bytes, { mode: 384 });
|
|
4463
|
+
renameSync4(tmp, target);
|
|
3523
4464
|
const part = {
|
|
3524
4465
|
id: randomUUID(),
|
|
3525
4466
|
sessionID,
|
|
@@ -3547,7 +4488,7 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
3547
4488
|
if (opts.enabled === false) return {};
|
|
3548
4489
|
const maxBytes = resolveMaxBytes(opts);
|
|
3549
4490
|
const maxImages = resolveMaxImages(opts);
|
|
3550
|
-
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ??
|
|
4491
|
+
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join8(homedir5(), ".local/share/opencode/lacode/images");
|
|
3551
4492
|
const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
|
|
3552
4493
|
return {
|
|
3553
4494
|
"command.execute.before": async (cmd, output) => {
|
|
@@ -3588,7 +4529,7 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
3588
4529
|
const name = basename2(filePath);
|
|
3589
4530
|
let size;
|
|
3590
4531
|
try {
|
|
3591
|
-
const stat =
|
|
4532
|
+
const stat = statSync5(filePath);
|
|
3592
4533
|
if (!stat.isFile()) {
|
|
3593
4534
|
skipped.push(name);
|
|
3594
4535
|
continue;
|
|
@@ -3604,7 +4545,7 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
3604
4545
|
}
|
|
3605
4546
|
let bytes;
|
|
3606
4547
|
try {
|
|
3607
|
-
bytes =
|
|
4548
|
+
bytes = readFileSync8(filePath);
|
|
3608
4549
|
} catch {
|
|
3609
4550
|
skipped.push(name);
|
|
3610
4551
|
continue;
|
|
@@ -3663,18 +4604,26 @@ function scrubSlotNames(text) {
|
|
|
3663
4604
|
// src/plugins/notify.ts
|
|
3664
4605
|
import { spawn as spawn2 } from "node:child_process";
|
|
3665
4606
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
3666
|
-
import { existsSync as
|
|
3667
|
-
import * as
|
|
4607
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
4608
|
+
import * as path4 from "node:path";
|
|
3668
4609
|
|
|
3669
4610
|
// src/lib/notify.ts
|
|
3670
|
-
import { closeSync as closeSync2, mkdirSync as
|
|
3671
|
-
import { homedir as
|
|
3672
|
-
import { basename as basename3, join as
|
|
4611
|
+
import { closeSync as closeSync2, mkdirSync as mkdirSync6, openSync as openSync2, readFileSync as readFileSync9, renameSync as renameSync5, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7, writeSync as writeSync2 } from "node:fs";
|
|
4612
|
+
import { homedir as homedir6 } from "node:os";
|
|
4613
|
+
import { basename as basename3, join as join9 } from "node:path";
|
|
3673
4614
|
var INTERNAL_TITLE_PREFIXES = [
|
|
3674
4615
|
// context-autoupdate spawns the cartographer through the session API, with no
|
|
3675
4616
|
// parentID, so it looks exactly like a root session going idle. Notifying for
|
|
3676
4617
|
// it would announce the cartographer's own bookkeeping as a finished task.
|
|
3677
|
-
"context (auto"
|
|
4618
|
+
"context (auto",
|
|
4619
|
+
// timeline spawns its summarizer through the session API with the same shape,
|
|
4620
|
+
// so it would otherwise announce the summarizer's own bookkeeping as a task.
|
|
4621
|
+
"timeline (auto",
|
|
4622
|
+
// pool probe sessions are created as root sessions with no parentID and the
|
|
4623
|
+
// title `pool probe (<model>)` (`src/lib/probe.ts`), so notification for
|
|
4624
|
+
// their idle/error would announce Lacode's own health-check as finished user
|
|
4625
|
+
// work.
|
|
4626
|
+
"pool probe"
|
|
3678
4627
|
];
|
|
3679
4628
|
var NOTIFY_OUTCOMES = ["done", "question", "permission", "error"];
|
|
3680
4629
|
var notifyMessage = (outcome, detail) => {
|
|
@@ -3846,7 +4795,7 @@ var Notifier = class {
|
|
|
3846
4795
|
return this.lastSent.size;
|
|
3847
4796
|
}
|
|
3848
4797
|
};
|
|
3849
|
-
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ??
|
|
4798
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join9(homedir6(), ".local", "share", "opencode", "lacode", "notify");
|
|
3850
4799
|
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
3851
4800
|
var CLAIM_WINDOW_MS = 5e3;
|
|
3852
4801
|
var isPidAlive2 = (pid) => {
|
|
@@ -3857,17 +4806,17 @@ var isPidAlive2 = (pid) => {
|
|
|
3857
4806
|
return error.code === "EPERM";
|
|
3858
4807
|
}
|
|
3859
4808
|
};
|
|
3860
|
-
var claimFile = (sessionID, kind) =>
|
|
4809
|
+
var claimFile = (sessionID, kind) => join9(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
|
|
3861
4810
|
var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
3862
|
-
const
|
|
4811
|
+
const path5 = claimFile(sessionID, kind);
|
|
3863
4812
|
try {
|
|
3864
|
-
|
|
4813
|
+
mkdirSync6(notificationStateDir(), { recursive: true });
|
|
3865
4814
|
} catch {
|
|
3866
4815
|
return true;
|
|
3867
4816
|
}
|
|
3868
4817
|
const take = () => {
|
|
3869
4818
|
try {
|
|
3870
|
-
const fd = openSync2(
|
|
4819
|
+
const fd = openSync2(path5, "wx");
|
|
3871
4820
|
try {
|
|
3872
4821
|
writeSync2(fd, `${process.pid} ${instanceId} ${kind}`);
|
|
3873
4822
|
} finally {
|
|
@@ -3885,7 +4834,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3885
4834
|
const judge = () => {
|
|
3886
4835
|
let raw;
|
|
3887
4836
|
try {
|
|
3888
|
-
raw =
|
|
4837
|
+
raw = readFileSync9(path5, "utf8").trim();
|
|
3889
4838
|
} catch (error) {
|
|
3890
4839
|
return error.code === "ENOENT" ? "gone" : "unknown";
|
|
3891
4840
|
}
|
|
@@ -3906,7 +4855,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3906
4855
|
}
|
|
3907
4856
|
let age = Number.POSITIVE_INFINITY;
|
|
3908
4857
|
try {
|
|
3909
|
-
age = Date.now() -
|
|
4858
|
+
age = Date.now() - statSync6(path5).mtimeMs;
|
|
3910
4859
|
} catch {
|
|
3911
4860
|
return "gone";
|
|
3912
4861
|
}
|
|
@@ -3923,7 +4872,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3923
4872
|
if (judged === "held") return false;
|
|
3924
4873
|
if (judged === "unknown") return true;
|
|
3925
4874
|
if (judged === "gone") return take() !== "held";
|
|
3926
|
-
const lockPath = `${
|
|
4875
|
+
const lockPath = `${path5}.takeover`;
|
|
3927
4876
|
const takeOver = () => {
|
|
3928
4877
|
const create = () => {
|
|
3929
4878
|
try {
|
|
@@ -3935,7 +4884,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3935
4884
|
}
|
|
3936
4885
|
return () => {
|
|
3937
4886
|
try {
|
|
3938
|
-
|
|
4887
|
+
unlinkSync5(lockPath);
|
|
3939
4888
|
} catch {
|
|
3940
4889
|
}
|
|
3941
4890
|
};
|
|
@@ -3949,9 +4898,9 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3949
4898
|
let lockHolder;
|
|
3950
4899
|
let lockAge = Number.POSITIVE_INFINITY;
|
|
3951
4900
|
try {
|
|
3952
|
-
const parsed = Number.parseInt(
|
|
4901
|
+
const parsed = Number.parseInt(readFileSync9(lockPath, "utf8").trim(), 10);
|
|
3953
4902
|
lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3954
|
-
lockAge = Date.now() -
|
|
4903
|
+
lockAge = Date.now() - statSync6(lockPath).mtimeMs;
|
|
3955
4904
|
} catch {
|
|
3956
4905
|
return create();
|
|
3957
4906
|
}
|
|
@@ -3959,7 +4908,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3959
4908
|
return void 0;
|
|
3960
4909
|
}
|
|
3961
4910
|
try {
|
|
3962
|
-
|
|
4911
|
+
unlinkSync5(lockPath);
|
|
3963
4912
|
} catch {
|
|
3964
4913
|
return void 0;
|
|
3965
4914
|
}
|
|
@@ -3973,7 +4922,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3973
4922
|
if (current === "held") return false;
|
|
3974
4923
|
if (current === "unknown") return true;
|
|
3975
4924
|
try {
|
|
3976
|
-
|
|
4925
|
+
unlinkSync5(path5);
|
|
3977
4926
|
} catch {
|
|
3978
4927
|
}
|
|
3979
4928
|
return take() !== "held";
|
|
@@ -3982,23 +4931,23 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
|
|
|
3982
4931
|
}
|
|
3983
4932
|
};
|
|
3984
4933
|
var releaseNotificationClaim = (sessionID, kind) => {
|
|
3985
|
-
const
|
|
4934
|
+
const path5 = claimFile(sessionID, kind);
|
|
3986
4935
|
try {
|
|
3987
|
-
const parsed = Number.parseInt(
|
|
4936
|
+
const parsed = Number.parseInt(readFileSync9(path5, "utf8").trim(), 10);
|
|
3988
4937
|
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
3989
4938
|
if (parsed !== process.pid) return;
|
|
3990
|
-
|
|
4939
|
+
unlinkSync5(path5);
|
|
3991
4940
|
} catch {
|
|
3992
4941
|
}
|
|
3993
4942
|
};
|
|
3994
|
-
var completionFile = (sessionID) =>
|
|
4943
|
+
var completionFile = (sessionID) => join9(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
|
|
3995
4944
|
var claimCompletion = (sessionID, kind) => {
|
|
3996
|
-
const
|
|
4945
|
+
const path5 = completionFile(sessionID);
|
|
3997
4946
|
try {
|
|
3998
|
-
|
|
3999
|
-
const fd = openSync2(
|
|
4947
|
+
mkdirSync6(notificationStateDir(), { recursive: true });
|
|
4948
|
+
const fd = openSync2(path5, "wx");
|
|
4000
4949
|
try {
|
|
4001
|
-
|
|
4950
|
+
writeFileSync7(fd, `${process.pid} ${kind}
|
|
4002
4951
|
`);
|
|
4003
4952
|
} finally {
|
|
4004
4953
|
closeSync2(fd);
|
|
@@ -4011,18 +4960,18 @@ var claimCompletion = (sessionID, kind) => {
|
|
|
4011
4960
|
};
|
|
4012
4961
|
var releaseCompletion = (sessionID) => {
|
|
4013
4962
|
try {
|
|
4014
|
-
|
|
4963
|
+
unlinkSync5(completionFile(sessionID));
|
|
4015
4964
|
} catch {
|
|
4016
4965
|
}
|
|
4017
4966
|
};
|
|
4018
4967
|
var markCompletion = (sessionID, kind) => {
|
|
4019
4968
|
try {
|
|
4020
|
-
|
|
4021
|
-
const
|
|
4022
|
-
const tmp = `${
|
|
4023
|
-
|
|
4969
|
+
mkdirSync6(notificationStateDir(), { recursive: true });
|
|
4970
|
+
const path5 = completionFile(sessionID);
|
|
4971
|
+
const tmp = `${path5}.${process.pid}.tmp`;
|
|
4972
|
+
writeFileSync7(tmp, `${process.pid} ${kind}
|
|
4024
4973
|
`);
|
|
4025
|
-
|
|
4974
|
+
renameSync5(tmp, path5);
|
|
4026
4975
|
} catch {
|
|
4027
4976
|
}
|
|
4028
4977
|
};
|
|
@@ -4043,13 +4992,13 @@ var findHelper = () => {
|
|
|
4043
4992
|
const home = process.env.HOME ?? "";
|
|
4044
4993
|
const candidates = [
|
|
4045
4994
|
process.env.OC_NOTIFY_HELPER,
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4995
|
+
path4.join(home, "bin", "lacode"),
|
|
4996
|
+
path4.join(PACKAGE_ROOT, "dist", "lacode.js"),
|
|
4997
|
+
path4.join(PACKAGE_ROOT, "bin", "lacode"),
|
|
4998
|
+
path4.join(home, "bin", "oc-notify"),
|
|
4999
|
+
path4.join(PACKAGE_ROOT, "bin", "oc-notify")
|
|
4051
5000
|
].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
|
|
4052
|
-
for (const candidate of candidates) if (
|
|
5001
|
+
for (const candidate of candidates) if (existsSync4(candidate)) return candidate;
|
|
4053
5002
|
return void 0;
|
|
4054
5003
|
};
|
|
4055
5004
|
var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
@@ -4162,10 +5111,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
4162
5111
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
4163
5112
|
return info;
|
|
4164
5113
|
};
|
|
4165
|
-
const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((
|
|
5114
|
+
const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve5) => {
|
|
4166
5115
|
if (inflight >= maxInflight) {
|
|
4167
5116
|
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
4168
|
-
|
|
5117
|
+
resolve5({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
4169
5118
|
return;
|
|
4170
5119
|
}
|
|
4171
5120
|
const helper = findHelper();
|
|
@@ -4174,7 +5123,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
4174
5123
|
warnedHelper = true;
|
|
4175
5124
|
void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
|
|
4176
5125
|
}
|
|
4177
|
-
|
|
5126
|
+
resolve5({ ok: false, detail: "no oc-notify helper found" });
|
|
4178
5127
|
return;
|
|
4179
5128
|
}
|
|
4180
5129
|
const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
|
|
@@ -4197,7 +5146,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
4197
5146
|
settled = true;
|
|
4198
5147
|
inflight--;
|
|
4199
5148
|
if (timer) clearTimeout(timer);
|
|
4200
|
-
|
|
5149
|
+
resolve5(result);
|
|
4201
5150
|
};
|
|
4202
5151
|
timer = setTimeout(() => {
|
|
4203
5152
|
try {
|
|
@@ -4518,9 +5467,9 @@ function normalizeBaseURL(raw) {
|
|
|
4518
5467
|
return null;
|
|
4519
5468
|
}
|
|
4520
5469
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
4521
|
-
let
|
|
4522
|
-
while (
|
|
4523
|
-
return `${url.origin}${
|
|
5470
|
+
let path5 = url.pathname.replace(/\/+$/, "");
|
|
5471
|
+
while (path5.endsWith("/v1")) path5 = path5.slice(0, -"/v1".length);
|
|
5472
|
+
return `${url.origin}${path5}/v1`;
|
|
4524
5473
|
}
|
|
4525
5474
|
function parseConfig(raw, env = process.env) {
|
|
4526
5475
|
if (typeof raw !== "object" || raw === null) return { enabled: false, config: null, problems: [] };
|
|
@@ -4986,6 +5935,741 @@ async function selfUpdateHooks(input, options) {
|
|
|
4986
5935
|
return {};
|
|
4987
5936
|
}
|
|
4988
5937
|
|
|
5938
|
+
// src/plugins/timeline.ts
|
|
5939
|
+
import { writeFileSync as writeFileSync8, renameSync as renameSync6, rmSync as rmSync3 } from "node:fs";
|
|
5940
|
+
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
5941
|
+
|
|
5942
|
+
// src/lib/timeline.ts
|
|
5943
|
+
import { appendFileSync, mkdirSync as mkdirSync7, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
|
|
5944
|
+
import { homedir as homedir7 } from "node:os";
|
|
5945
|
+
import { dirname as dirname5, join as join11, resolve as resolve4 } from "node:path";
|
|
5946
|
+
function timelineDir() {
|
|
5947
|
+
const env = process.env.LACODE_TIMELINE_DIR;
|
|
5948
|
+
if (env) return resolve4(env);
|
|
5949
|
+
return join11(homedir7(), ".local/share/opencode/lacode/timeline");
|
|
5950
|
+
}
|
|
5951
|
+
function timelineFile(sessionID) {
|
|
5952
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
|
|
5953
|
+
return join11(timelineDir(), sessionID + ".jsonl");
|
|
5954
|
+
}
|
|
5955
|
+
function normalizeThrough(through) {
|
|
5956
|
+
if (!through || typeof through !== "object") return void 0;
|
|
5957
|
+
const out = {};
|
|
5958
|
+
if (typeof through.messageID === "string") out.messageID = through.messageID;
|
|
5959
|
+
if (typeof through.time === "number") out.time = through.time;
|
|
5960
|
+
if (out.messageID === void 0 && out.time === void 0) return void 0;
|
|
5961
|
+
return out;
|
|
5962
|
+
}
|
|
5963
|
+
function appendEntriesAt(file, texts, now, through) {
|
|
5964
|
+
mkdirSync7(dirname5(file), { recursive: true });
|
|
5965
|
+
const cleaned = [];
|
|
5966
|
+
for (const t of texts) {
|
|
5967
|
+
if (typeof t !== "string" || t.length === 0) continue;
|
|
5968
|
+
cleaned.push(t.slice(0, 320));
|
|
5969
|
+
}
|
|
5970
|
+
let recent = [];
|
|
5971
|
+
try {
|
|
5972
|
+
recent = readEntriesAt(file, 20);
|
|
5973
|
+
} catch {
|
|
5974
|
+
recent = [];
|
|
5975
|
+
}
|
|
5976
|
+
const recentSet = new Set(recent.map((e) => e.text.toLowerCase()));
|
|
5977
|
+
const batchSeen = /* @__PURE__ */ new Set();
|
|
5978
|
+
const toAppend = [];
|
|
5979
|
+
for (const t of cleaned) {
|
|
5980
|
+
const key = t.toLowerCase();
|
|
5981
|
+
if (recentSet.has(key)) continue;
|
|
5982
|
+
if (batchSeen.has(key)) continue;
|
|
5983
|
+
batchSeen.add(key);
|
|
5984
|
+
toAppend.push(t);
|
|
5985
|
+
}
|
|
5986
|
+
const validThrough = normalizeThrough(through);
|
|
5987
|
+
let count = 0;
|
|
5988
|
+
for (const t of toAppend) {
|
|
5989
|
+
const ts = now ?? Date.now();
|
|
5990
|
+
const line = { ts, text: t };
|
|
5991
|
+
if (validThrough) line.through = validThrough;
|
|
5992
|
+
appendFileSync(file, JSON.stringify(line) + "\n");
|
|
5993
|
+
count++;
|
|
5994
|
+
}
|
|
5995
|
+
return count;
|
|
5996
|
+
}
|
|
5997
|
+
function readEntriesAt(file, limit) {
|
|
5998
|
+
let raw = "";
|
|
5999
|
+
try {
|
|
6000
|
+
raw = readFileSync10(file, "utf8");
|
|
6001
|
+
} catch {
|
|
6002
|
+
return [];
|
|
6003
|
+
}
|
|
6004
|
+
const entries = [];
|
|
6005
|
+
for (const line of raw.split("\n")) {
|
|
6006
|
+
if (line.trim() === "") continue;
|
|
6007
|
+
try {
|
|
6008
|
+
const obj = JSON.parse(line);
|
|
6009
|
+
if (obj && typeof obj.ts === "number" && typeof obj.text === "string") {
|
|
6010
|
+
const entry = { ts: obj.ts, text: obj.text };
|
|
6011
|
+
const t = normalizeThrough(obj.through);
|
|
6012
|
+
if (t) entry.through = t;
|
|
6013
|
+
entries.push(entry);
|
|
6014
|
+
}
|
|
6015
|
+
} catch {
|
|
6016
|
+
}
|
|
6017
|
+
}
|
|
6018
|
+
if (limit != null && limit <= 0) return [];
|
|
6019
|
+
const lim = limit == null ? 50 : limit;
|
|
6020
|
+
return entries.slice(-lim);
|
|
6021
|
+
}
|
|
6022
|
+
function appendEntries(sessionID, texts, now, through) {
|
|
6023
|
+
return appendEntriesAt(timelineFile(sessionID), texts, now, through);
|
|
6024
|
+
}
|
|
6025
|
+
function readEntries(sessionID, limit) {
|
|
6026
|
+
return readEntriesAt(timelineFile(sessionID), limit);
|
|
6027
|
+
}
|
|
6028
|
+
function removeEntries(sessionID) {
|
|
6029
|
+
try {
|
|
6030
|
+
rmSync2(timelineFile(sessionID), { force: true });
|
|
6031
|
+
} catch {
|
|
6032
|
+
}
|
|
6033
|
+
}
|
|
6034
|
+
function collapse(s) {
|
|
6035
|
+
return s.replace(/\s+/g, " ").trim();
|
|
6036
|
+
}
|
|
6037
|
+
function trunc(s, n) {
|
|
6038
|
+
return s.length > n ? s.slice(0, n) : s;
|
|
6039
|
+
}
|
|
6040
|
+
function stripAnsiAndControls(s) {
|
|
6041
|
+
let out = "";
|
|
6042
|
+
for (let i = 0; i < s.length; i++) {
|
|
6043
|
+
const code = s.charCodeAt(i);
|
|
6044
|
+
if (code === 27) {
|
|
6045
|
+
if (i + 1 < s.length && s[i + 1] === "[") {
|
|
6046
|
+
i += 2;
|
|
6047
|
+
while (i < s.length) {
|
|
6048
|
+
const c = s.charCodeAt(i);
|
|
6049
|
+
if (c >= 64 && c <= 126) {
|
|
6050
|
+
break;
|
|
6051
|
+
}
|
|
6052
|
+
i++;
|
|
6053
|
+
}
|
|
6054
|
+
continue;
|
|
6055
|
+
}
|
|
6056
|
+
if (i + 1 < s.length && s[i + 1] === "]") {
|
|
6057
|
+
i += 2;
|
|
6058
|
+
while (i < s.length) {
|
|
6059
|
+
const c = s.charCodeAt(i);
|
|
6060
|
+
if (c === 7) break;
|
|
6061
|
+
if (c === 27 && i + 1 < s.length && s[i + 1] === "\\") {
|
|
6062
|
+
i += 1;
|
|
6063
|
+
break;
|
|
6064
|
+
}
|
|
6065
|
+
i++;
|
|
6066
|
+
}
|
|
6067
|
+
continue;
|
|
6068
|
+
}
|
|
6069
|
+
if (i + 1 < s.length) {
|
|
6070
|
+
i += 1;
|
|
6071
|
+
}
|
|
6072
|
+
continue;
|
|
6073
|
+
}
|
|
6074
|
+
if (code === 9 || code === 10) {
|
|
6075
|
+
out += s[i];
|
|
6076
|
+
continue;
|
|
6077
|
+
}
|
|
6078
|
+
if (code < 32 || code >= 127 && code <= 159) {
|
|
6079
|
+
continue;
|
|
6080
|
+
}
|
|
6081
|
+
out += s[i];
|
|
6082
|
+
}
|
|
6083
|
+
return out;
|
|
6084
|
+
}
|
|
6085
|
+
function inputSummary(input) {
|
|
6086
|
+
if (input === null || input === void 0) return "";
|
|
6087
|
+
if (typeof input !== "object") {
|
|
6088
|
+
try {
|
|
6089
|
+
return trunc(collapse(JSON.stringify(input)), 160);
|
|
6090
|
+
} catch {
|
|
6091
|
+
return "";
|
|
6092
|
+
}
|
|
6093
|
+
}
|
|
6094
|
+
const rec = input;
|
|
6095
|
+
const keys = ["filePath", "path", "command", "description", "pattern", "query", "url", "name", "title"];
|
|
6096
|
+
for (const k of keys) {
|
|
6097
|
+
if (Object.prototype.hasOwnProperty.call(rec, k) && rec[k] != null) {
|
|
6098
|
+
const v = rec[k];
|
|
6099
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
6100
|
+
return trunc(collapse(s), 160);
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
try {
|
|
6104
|
+
return trunc(collapse(JSON.stringify(rec)), 160);
|
|
6105
|
+
} catch {
|
|
6106
|
+
return "";
|
|
6107
|
+
}
|
|
6108
|
+
}
|
|
6109
|
+
function summarizeToolResult(state2) {
|
|
6110
|
+
if (!state2) return "";
|
|
6111
|
+
const err = state2.error;
|
|
6112
|
+
if (typeof err === "string") {
|
|
6113
|
+
if (err.trim() === "") return "";
|
|
6114
|
+
return `error: ${trunc(err, 180)}`;
|
|
6115
|
+
}
|
|
6116
|
+
if (err && typeof err === "object") {
|
|
6117
|
+
try {
|
|
6118
|
+
const s = JSON.stringify(err);
|
|
6119
|
+
if (typeof s === "string" && s.trim() !== "") {
|
|
6120
|
+
return trunc(s, 180);
|
|
6121
|
+
}
|
|
6122
|
+
} catch {
|
|
6123
|
+
}
|
|
6124
|
+
return "";
|
|
6125
|
+
}
|
|
6126
|
+
if (typeof state2.output === "string") {
|
|
6127
|
+
for (const raw of state2.output.split("\n")) {
|
|
6128
|
+
const line = raw.trim();
|
|
6129
|
+
if (line.length > 0) {
|
|
6130
|
+
return trunc(line, 180);
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
}
|
|
6134
|
+
if (typeof state2.title === "string" && state2.title.trim() !== "") {
|
|
6135
|
+
return trunc(state2.title, 180);
|
|
6136
|
+
}
|
|
6137
|
+
return "";
|
|
6138
|
+
}
|
|
6139
|
+
function renderMessage(m) {
|
|
6140
|
+
const role = m.info.role;
|
|
6141
|
+
const lines = [];
|
|
6142
|
+
if (role === "user") {
|
|
6143
|
+
for (const p of m.parts) {
|
|
6144
|
+
if (p.type === "text" && !p.synthetic && !p.ignored) {
|
|
6145
|
+
if ((p.text ?? "").includes("timeline_reprocess")) continue;
|
|
6146
|
+
const text = collapse(p.text ?? "");
|
|
6147
|
+
lines.push(`[user] ${trunc(text, 2e3)}`);
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
} else if (role === "assistant") {
|
|
6151
|
+
for (const p of m.parts) {
|
|
6152
|
+
if (p.type === "text") {
|
|
6153
|
+
const text = collapse(p.text ?? "");
|
|
6154
|
+
lines.push(`[assistant] ${trunc(text, 1500)}`);
|
|
6155
|
+
} else if (p.type === "tool") {
|
|
6156
|
+
const tool3 = p.tool ?? "";
|
|
6157
|
+
if (tool3 === "timeline_reprocess") continue;
|
|
6158
|
+
const status = p.state?.status;
|
|
6159
|
+
const summary = inputSummary(p.state?.input);
|
|
6160
|
+
const result = summarizeToolResult(p.state);
|
|
6161
|
+
let line = `[tool] ${tool3}`;
|
|
6162
|
+
if (status) line += ` (${status})`;
|
|
6163
|
+
const inputPart = summary ? trunc(summary, 220) : "";
|
|
6164
|
+
let rest = "";
|
|
6165
|
+
if (inputPart) rest += ` ${inputPart}`;
|
|
6166
|
+
if (result) rest += ` -> ${result}`;
|
|
6167
|
+
line += rest;
|
|
6168
|
+
lines.push(line);
|
|
6169
|
+
}
|
|
6170
|
+
}
|
|
6171
|
+
}
|
|
6172
|
+
return lines;
|
|
6173
|
+
}
|
|
6174
|
+
function buildExcerpt(messages, cursor, budget = 8e3) {
|
|
6175
|
+
let start = 0;
|
|
6176
|
+
if (cursor?.messageID) {
|
|
6177
|
+
const idx = messages.findIndex((m) => m.info.id === cursor.messageID);
|
|
6178
|
+
if (idx >= 0) {
|
|
6179
|
+
start = idx + 1;
|
|
6180
|
+
} else if (cursor.time != null) {
|
|
6181
|
+
const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
|
|
6182
|
+
start = i < 0 ? messages.length : i;
|
|
6183
|
+
}
|
|
6184
|
+
} else if (cursor?.time != null) {
|
|
6185
|
+
const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
|
|
6186
|
+
start = i < 0 ? messages.length : i;
|
|
6187
|
+
}
|
|
6188
|
+
const outLines = [];
|
|
6189
|
+
const included = [];
|
|
6190
|
+
for (let i = start; i < messages.length; i++) {
|
|
6191
|
+
const m = messages[i];
|
|
6192
|
+
const msgLines = renderMessage(m);
|
|
6193
|
+
if (msgLines.length === 0) continue;
|
|
6194
|
+
const candidateLines = outLines.length === 0 ? [...msgLines] : [...outLines, ...msgLines];
|
|
6195
|
+
const candidateText = candidateLines.join("\n");
|
|
6196
|
+
if (included.length > 0 && candidateText.length > budget) {
|
|
6197
|
+
break;
|
|
6198
|
+
}
|
|
6199
|
+
if (included.length === 0 && candidateText.length > budget) {
|
|
6200
|
+
const block = msgLines.join("\n");
|
|
6201
|
+
outLines.push(block.slice(0, budget));
|
|
6202
|
+
included.push(m);
|
|
6203
|
+
break;
|
|
6204
|
+
}
|
|
6205
|
+
outLines.push(...msgLines);
|
|
6206
|
+
included.push(m);
|
|
6207
|
+
}
|
|
6208
|
+
let model;
|
|
6209
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
6210
|
+
const info = messages[i].info;
|
|
6211
|
+
if (info.role !== "user") continue;
|
|
6212
|
+
if (info.model) {
|
|
6213
|
+
model = { providerID: info.model.providerID, modelID: info.model.modelID };
|
|
6214
|
+
break;
|
|
6215
|
+
}
|
|
6216
|
+
if (info.providerID && info.modelID) {
|
|
6217
|
+
model = { providerID: info.providerID, modelID: info.modelID };
|
|
6218
|
+
break;
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
6221
|
+
const lastIncluded = included[included.length - 1];
|
|
6222
|
+
const next = lastIncluded ? { messageID: lastIncluded.info.id, time: lastIncluded.info.time?.created } : void 0;
|
|
6223
|
+
return {
|
|
6224
|
+
text: outLines.join("\n"),
|
|
6225
|
+
next,
|
|
6226
|
+
count: included.length,
|
|
6227
|
+
model
|
|
6228
|
+
};
|
|
6229
|
+
}
|
|
6230
|
+
function parseBullets(text, max = 6) {
|
|
6231
|
+
const result = [];
|
|
6232
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6233
|
+
for (const raw of text.split("\n")) {
|
|
6234
|
+
const line = raw.trim();
|
|
6235
|
+
if (!line) continue;
|
|
6236
|
+
if (line.startsWith("```")) continue;
|
|
6237
|
+
if (line.length > 320) continue;
|
|
6238
|
+
const m = line.match(/^([-*•]|\d+[.)])\s+(.*)$/);
|
|
6239
|
+
if (!m) continue;
|
|
6240
|
+
const stripped = m[2].trim().replace(/`/g, "").replace(/\*\*/g, "").trim();
|
|
6241
|
+
if (!stripped) continue;
|
|
6242
|
+
const cleaned = stripAnsiAndControls(stripped).trim();
|
|
6243
|
+
if (!cleaned) continue;
|
|
6244
|
+
if (cleaned.length > 320) continue;
|
|
6245
|
+
const key = cleaned.toLowerCase();
|
|
6246
|
+
if (seen.has(key)) continue;
|
|
6247
|
+
seen.add(key);
|
|
6248
|
+
result.push(cleaned);
|
|
6249
|
+
if (result.length >= max) break;
|
|
6250
|
+
}
|
|
6251
|
+
return result;
|
|
6252
|
+
}
|
|
6253
|
+
function isInternalTitle(title) {
|
|
6254
|
+
const t = (title ?? "").trim();
|
|
6255
|
+
return t.startsWith("context (auto") || t.startsWith("timeline (auto") || t.startsWith("pool probe");
|
|
6256
|
+
}
|
|
6257
|
+
|
|
6258
|
+
// src/plugins/timeline.ts
|
|
6259
|
+
var DEFAULT_INTERVAL_MS = 3e5;
|
|
6260
|
+
var MIN_INTERVAL_MS = 1e4;
|
|
6261
|
+
var DEFAULT_MAX_ENTRIES = 8;
|
|
6262
|
+
var MAX_ENTRIES = 10;
|
|
6263
|
+
var PROMPT_TIMEOUT_MS = 9e4;
|
|
6264
|
+
var SYSTEM = `You are the session timeline summarizer. You receive a slice of a coding session transcript.
|
|
6265
|
+
Write 1-8 timeline entries describing the work as tasks, features, fixes, or stages.
|
|
6266
|
+
|
|
6267
|
+
Rules:
|
|
6268
|
+
- One entry per line, each starting with "- ".
|
|
6269
|
+
- Each entry is 6-16 words, past tense, plain text.
|
|
6270
|
+
- Cover each distinct task in the slice. Do not drop a provider, feature, fix, config, docs, or test change; merge only exact duplicates.
|
|
6271
|
+
- Describe the TASK or OUTCOME, not the implementation. "Pool: added bucket-based routing" is right; "added b:<name> to pool.ts and agent-pool.ts" is too detailed.
|
|
6272
|
+
- Never describe the mechanism: no flags, aliases, overrides, endpoints, probes, config keys, declarations, tags, or file changes. State what changed for the system: which capability now works, which bug is fixed, which behavior changed.
|
|
6273
|
+
- Never use these words in an entry: tag, alias, override, flag, probe, endpoint, config key, catalog, file, line, count.
|
|
6274
|
+
- For configuration plumbing, write the purpose: "Config: silenced warnings for unused providers", not "declared empty overrides".
|
|
6275
|
+
- Use a short area prefix when it clarifies: "Pool:", "Providers:", "Config:", "Docs:", "Tests:", "Fix:", "Feature:".
|
|
6276
|
+
- Name the capability, behavior, feature, bug, or decision. Do not name files, paths, functions, commands, environment variables, API keys, raw model IDs, test file names, or build artifacts. Provider and product names are allowed when they identify the task.
|
|
6277
|
+
- Do not include counts of slots, providers, tests, lines, or files.
|
|
6278
|
+
- Omit internal housekeeping: builds, dist rebuilds, test-file additions, formatting, and dependency churn. If the slice only contains housekeeping, output nothing.
|
|
6279
|
+
- Describe what was DONE or DECIDED. Never write "User asked...", "Assistant said...", "Discussed...", "Reviewed...", "Explained...", or "Considered...".
|
|
6280
|
+
- Merge related changes into one stage; do not repeat the same task in different words.
|
|
6281
|
+
- No markdown: no bold, italics, backticks, or code blocks.
|
|
6282
|
+
- If a technical detail is the essence of the task, name it at the capability level: "Added Cloudflare Workers AI as a provider", not its account-id variables.
|
|
6283
|
+
- If the slice contains no substantive work or decision, output nothing.
|
|
6284
|
+
- Output only the entries. No headings, no preamble, no closing sentence.
|
|
6285
|
+
|
|
6286
|
+
Example output:
|
|
6287
|
+
- Pool: added bucket-based routing for model groups.
|
|
6288
|
+
- Providers: added Gemini 3.5 Flash after the previous model timed out.
|
|
6289
|
+
- Providers: added Cloudflare Workers AI with a tool-compatible model.
|
|
6290
|
+
- Config: silenced warnings for unused providers.
|
|
6291
|
+
- Config: standardized provider key naming and added Groq and Google.
|
|
6292
|
+
- Docs: aligned the routing documentation with the implemented behavior.
|
|
6293
|
+
|
|
6294
|
+
Not this:
|
|
6295
|
+
- Config: declared empty OpenRouter overrides to silence warnings.
|
|
6296
|
+
- Providers: added a Google API key alias after Gemini 2.5-flash closure.
|
|
6297
|
+
- Config: standardized provider keys to _KEY-only naming.
|
|
6298
|
+
- Pool: reordered slots after tagging pool entries.
|
|
6299
|
+
- Pool: added bucket support and kept tags in pool.
|
|
6300
|
+
- Pool: standardized slots on opencode catalog.
|
|
6301
|
+
- Providers: added Gemini 3.5 Flash and a Google API key alias.
|
|
6302
|
+
|
|
6303
|
+
This:
|
|
6304
|
+
- Config: silenced warnings for unused providers.
|
|
6305
|
+
- Providers: replaced the retired Gemini 2.5 Flash with an available model.
|
|
6306
|
+
- Config: standardized provider key naming.
|
|
6307
|
+
- Pool: moved shared-window models to the end.
|
|
6308
|
+
- Pool: added named bucket support for model groups.
|
|
6309
|
+
- Pool: moved shared-window models to the end.
|
|
6310
|
+
- Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
|
|
6311
|
+
|
|
6312
|
+
Your entire response must consist only of lines beginning with "- ".`;
|
|
6313
|
+
function timelineHooks(input, options) {
|
|
6314
|
+
const { client, directory } = input;
|
|
6315
|
+
const opts = options ?? {};
|
|
6316
|
+
const config = typeof opts.timeline === "object" && opts.timeline !== null ? opts.timeline : void 0;
|
|
6317
|
+
const disabled = opts.timeline === false || config?.enabled === false || process.env.LACODE_TIMELINE === "0";
|
|
6318
|
+
const SERVICE5 = "timeline";
|
|
6319
|
+
const poolState = state(options);
|
|
6320
|
+
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE5, level, message, extra } }).catch(() => {
|
|
6321
|
+
});
|
|
6322
|
+
const debug = (...args) => {
|
|
6323
|
+
if (process.env.LACODE_TIMELINE_DEBUG === "1") console.error("[timeline]", ...args);
|
|
6324
|
+
};
|
|
6325
|
+
const rawInterval = Number(process.env.LACODE_TIMELINE_INTERVAL_MS ?? config?.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
6326
|
+
const intervalMs = Number.isFinite(rawInterval) ? Math.max(MIN_INTERVAL_MS, rawInterval) : DEFAULT_INTERVAL_MS;
|
|
6327
|
+
const rawMaxEntries = Number(config?.maxEntries ?? DEFAULT_MAX_ENTRIES);
|
|
6328
|
+
const maxEntries = Number.isFinite(rawMaxEntries) ? Math.min(MAX_ENTRIES, Math.max(1, Math.trunc(rawMaxEntries))) : DEFAULT_MAX_ENTRIES;
|
|
6329
|
+
const shared = globalThis[/* @__PURE__ */ Symbol.for("lacode:timeline:state")] ??= {
|
|
6330
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
6331
|
+
owned: /* @__PURE__ */ new Set(),
|
|
6332
|
+
reprocessing: /* @__PURE__ */ new Set()
|
|
6333
|
+
};
|
|
6334
|
+
const sessions = shared.sessions;
|
|
6335
|
+
const owned = shared.owned;
|
|
6336
|
+
const reprocessing = shared.reprocessing;
|
|
6337
|
+
const MAX_REPROCESS_SLICES = 100;
|
|
6338
|
+
let disposed = false;
|
|
6339
|
+
const promptModel = (raw) => {
|
|
6340
|
+
if (typeof raw !== "string" || !raw.includes("/")) return void 0;
|
|
6341
|
+
const { provider, id } = splitModel(raw);
|
|
6342
|
+
if (!id) return void 0;
|
|
6343
|
+
return { providerID: provider, modelID: id };
|
|
6344
|
+
};
|
|
6345
|
+
const stateFor = (id) => {
|
|
6346
|
+
let state2 = sessions.get(id);
|
|
6347
|
+
if (!state2) {
|
|
6348
|
+
state2 = {
|
|
6349
|
+
running: false,
|
|
6350
|
+
pending: false,
|
|
6351
|
+
failures: 0,
|
|
6352
|
+
cursor: readEntries(id, 1)[0]?.through
|
|
6353
|
+
};
|
|
6354
|
+
sessions.set(id, state2);
|
|
6355
|
+
while (sessions.size > 500) {
|
|
6356
|
+
let evicted = false;
|
|
6357
|
+
for (const [key, st] of sessions) {
|
|
6358
|
+
if (key === id) continue;
|
|
6359
|
+
if (!st.timer && !st.running) {
|
|
6360
|
+
sessions.delete(key);
|
|
6361
|
+
evicted = true;
|
|
6362
|
+
break;
|
|
6363
|
+
}
|
|
6364
|
+
}
|
|
6365
|
+
if (!evicted) break;
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
return state2;
|
|
6369
|
+
};
|
|
6370
|
+
const note = (id) => {
|
|
6371
|
+
if (disabled || disposed) return;
|
|
6372
|
+
const state2 = stateFor(id);
|
|
6373
|
+
if (state2.root === false) return;
|
|
6374
|
+
state2.pending = true;
|
|
6375
|
+
if (state2.timer || state2.running) return;
|
|
6376
|
+
schedule(id, state2);
|
|
6377
|
+
};
|
|
6378
|
+
function schedule(id, state2) {
|
|
6379
|
+
if (state2.timer) clearTimeout(state2.timer);
|
|
6380
|
+
state2.timer = setTimeout(() => {
|
|
6381
|
+
state2.timer = void 0;
|
|
6382
|
+
void tick(id, state2);
|
|
6383
|
+
}, intervalMs);
|
|
6384
|
+
state2.timer.unref?.();
|
|
6385
|
+
}
|
|
6386
|
+
async function summarize(id, title, excerptText, prior, abort) {
|
|
6387
|
+
if (abort?.aborted) return { ok: false, bullets: [], error: "aborted" };
|
|
6388
|
+
let slot;
|
|
6389
|
+
try {
|
|
6390
|
+
init(poolState, (m) => log("warn", m));
|
|
6391
|
+
const snap = snapshot(poolState);
|
|
6392
|
+
slot = pickSlot(poolState, snap);
|
|
6393
|
+
} catch (error) {
|
|
6394
|
+
log("warn", String(error));
|
|
6395
|
+
return { ok: false, bullets: [], error: String(error) };
|
|
6396
|
+
}
|
|
6397
|
+
const model = promptModel(slot?.model);
|
|
6398
|
+
if (!model) {
|
|
6399
|
+
log("warn", "timeline: slot model cannot be split into provider/model", { model: slot?.model });
|
|
6400
|
+
return { ok: false, bullets: [], error: "slot model cannot be split into provider/model" };
|
|
6401
|
+
}
|
|
6402
|
+
const created = await client.session.create({
|
|
6403
|
+
body: { title: "timeline (auto)" },
|
|
6404
|
+
query: { directory }
|
|
6405
|
+
});
|
|
6406
|
+
const target = created?.data?.id;
|
|
6407
|
+
if (!target) {
|
|
6408
|
+
return { ok: false, bullets: [], error: "session create returned no id" };
|
|
6409
|
+
}
|
|
6410
|
+
const claimKey2 = claimKey(target, "timeline");
|
|
6411
|
+
let claimed = false;
|
|
6412
|
+
owned.add(target);
|
|
6413
|
+
let abortListener;
|
|
6414
|
+
try {
|
|
6415
|
+
claimed = true;
|
|
6416
|
+
acquire(poolState, "context", slot, target, claimKey2, target);
|
|
6417
|
+
const prompt = [
|
|
6418
|
+
`Session: ${title}`,
|
|
6419
|
+
...prior.length > 0 ? ["Existing timeline (do not repeat):", ...prior.map((entry) => `- ${entry.text}`)] : [],
|
|
6420
|
+
"Transcript slice:",
|
|
6421
|
+
excerptText
|
|
6422
|
+
].join("\n");
|
|
6423
|
+
if (abort) {
|
|
6424
|
+
abortListener = () => {
|
|
6425
|
+
void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
|
|
6426
|
+
});
|
|
6427
|
+
};
|
|
6428
|
+
abort.addEventListener("abort", abortListener);
|
|
6429
|
+
}
|
|
6430
|
+
let timer;
|
|
6431
|
+
let timedOut = false;
|
|
6432
|
+
let result;
|
|
6433
|
+
try {
|
|
6434
|
+
const answer = client.session.prompt({
|
|
6435
|
+
path: { id: target },
|
|
6436
|
+
query: { directory },
|
|
6437
|
+
body: {
|
|
6438
|
+
model,
|
|
6439
|
+
tools: { "*": false },
|
|
6440
|
+
system: SYSTEM,
|
|
6441
|
+
parts: [{ type: "text", text: prompt }]
|
|
6442
|
+
}
|
|
6443
|
+
}).then(
|
|
6444
|
+
(value) => value,
|
|
6445
|
+
(error) => ({ error })
|
|
6446
|
+
);
|
|
6447
|
+
const deadline = new Promise((resolve5) => {
|
|
6448
|
+
timer = setTimeout(() => {
|
|
6449
|
+
timedOut = true;
|
|
6450
|
+
void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
|
|
6451
|
+
});
|
|
6452
|
+
resolve5(void 0);
|
|
6453
|
+
}, PROMPT_TIMEOUT_MS);
|
|
6454
|
+
});
|
|
6455
|
+
result = await Promise.race([answer, deadline]);
|
|
6456
|
+
} finally {
|
|
6457
|
+
if (timer) clearTimeout(timer);
|
|
6458
|
+
}
|
|
6459
|
+
if (abort?.aborted) {
|
|
6460
|
+
return { ok: false, bullets: [], error: "aborted" };
|
|
6461
|
+
}
|
|
6462
|
+
const failure = result?.error ?? result?.data?.info?.error;
|
|
6463
|
+
if (timedOut || failure !== void 0) {
|
|
6464
|
+
return { ok: false, bullets: [], error: timedOut ? "timed out" : String(failure) };
|
|
6465
|
+
} else {
|
|
6466
|
+
const text = (result?.data?.parts ?? []).map((part) => part.type === "text" ? part.text ?? "" : "").join("\n");
|
|
6467
|
+
const bullets = parseBullets(text, maxEntries);
|
|
6468
|
+
if (bullets.length > 0) {
|
|
6469
|
+
return { ok: true, bullets };
|
|
6470
|
+
} else if (text.trim() === "") {
|
|
6471
|
+
return { ok: true, bullets: [] };
|
|
6472
|
+
} else {
|
|
6473
|
+
if (process.env.LACODE_TIMELINE_DEBUG === "1") {
|
|
6474
|
+
log("no bullet lines", { sessionID: id, text: text.slice(0, 300) });
|
|
6475
|
+
}
|
|
6476
|
+
return { ok: false, bullets: [], error: "no bullet lines" };
|
|
6477
|
+
}
|
|
6478
|
+
}
|
|
6479
|
+
} finally {
|
|
6480
|
+
if (abort && abortListener) abort.removeEventListener("abort", abortListener);
|
|
6481
|
+
if (claimed && claimKey2) release(poolState, claimKey2);
|
|
6482
|
+
await client.session.delete({ path: { id: target }, query: { directory } }).catch(() => {
|
|
6483
|
+
});
|
|
6484
|
+
owned.delete(target);
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
async function tick(id, state2) {
|
|
6488
|
+
if (disposed) return;
|
|
6489
|
+
if (reprocessing.has(id)) return;
|
|
6490
|
+
try {
|
|
6491
|
+
state2.running = true;
|
|
6492
|
+
state2.pending = false;
|
|
6493
|
+
let title;
|
|
6494
|
+
let info = {};
|
|
6495
|
+
if (state2.root === void 0) {
|
|
6496
|
+
info = await client.session.get({ path: { id }, query: { directory } }).then((result2) => result2?.data).catch(() => void 0);
|
|
6497
|
+
if (!info) {
|
|
6498
|
+
sessions.delete(id);
|
|
6499
|
+
return;
|
|
6500
|
+
}
|
|
6501
|
+
title = info.title;
|
|
6502
|
+
state2.root = !info.parentID && !isInternalTitle(info.title);
|
|
6503
|
+
if (!state2.root) {
|
|
6504
|
+
sessions.delete(id);
|
|
6505
|
+
return;
|
|
6506
|
+
}
|
|
6507
|
+
}
|
|
6508
|
+
const response = await client.session.messages({ path: { id }, query: { directory } });
|
|
6509
|
+
const messages = response?.data ?? [];
|
|
6510
|
+
const excerpt = buildExcerpt(messages, state2.cursor);
|
|
6511
|
+
if (!excerpt.text) {
|
|
6512
|
+
return;
|
|
6513
|
+
}
|
|
6514
|
+
const result = await summarize(id, info.title ?? "untitled", excerpt.text, readEntries(id, 4));
|
|
6515
|
+
if (result.ok) {
|
|
6516
|
+
if (result.bullets.length) appendEntries(id, result.bullets, Date.now(), excerpt.next);
|
|
6517
|
+
state2.cursor = excerpt.next;
|
|
6518
|
+
state2.failures = 0;
|
|
6519
|
+
} else {
|
|
6520
|
+
state2.failures++;
|
|
6521
|
+
log("summarize failed", { sessionID: id, error: result.error });
|
|
6522
|
+
}
|
|
6523
|
+
} catch (error) {
|
|
6524
|
+
debug("tick failed", { sessionID: id, error });
|
|
6525
|
+
} finally {
|
|
6526
|
+
state2.running = false;
|
|
6527
|
+
if (sessions.get(id) === state2 && state2.pending && !disabled && !disposed && state2.root !== false) {
|
|
6528
|
+
schedule(id, state2);
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
async function reprocess(id, abort) {
|
|
6533
|
+
if (reprocessing.has(id)) return "A timeline reprocess is already running for this session.";
|
|
6534
|
+
if (sessions.get(id)?.running) return "The timeline is currently summarizing this session; try again in a moment.";
|
|
6535
|
+
reprocessing.add(id);
|
|
6536
|
+
const targetFile = timelineFile(id);
|
|
6537
|
+
const temp = targetFile + ".reprocess." + process.pid + "." + Date.now() + ".tmp";
|
|
6538
|
+
let cursor;
|
|
6539
|
+
let entries = 0;
|
|
6540
|
+
let calls = 0;
|
|
6541
|
+
try {
|
|
6542
|
+
const infoRes = await client.session.get({ path: { id }, query: { directory } });
|
|
6543
|
+
const info = infoRes?.data;
|
|
6544
|
+
if (!info) return "Session not found.";
|
|
6545
|
+
const msgRes = await client.session.messages({ path: { id }, query: { directory } });
|
|
6546
|
+
const messages = msgRes?.data ?? [];
|
|
6547
|
+
writeFileSync8(temp, "");
|
|
6548
|
+
for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
|
|
6549
|
+
if (abort?.aborted) {
|
|
6550
|
+
try {
|
|
6551
|
+
rmSync3(temp, { force: true });
|
|
6552
|
+
} catch {
|
|
6553
|
+
}
|
|
6554
|
+
return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
|
|
6555
|
+
}
|
|
6556
|
+
const excerpt = buildExcerpt(messages, cursor);
|
|
6557
|
+
if (!excerpt.text) break;
|
|
6558
|
+
const prior = readEntriesAt(temp, 4);
|
|
6559
|
+
const result = await summarize(id, info.title ?? "untitled", excerpt.text, prior, abort);
|
|
6560
|
+
calls++;
|
|
6561
|
+
if (!result.ok) {
|
|
6562
|
+
try {
|
|
6563
|
+
rmSync3(temp, { force: true });
|
|
6564
|
+
} catch {
|
|
6565
|
+
}
|
|
6566
|
+
return `Stopped after ${calls} summarizer calls: ${result.error}. Previous timeline kept.`;
|
|
6567
|
+
}
|
|
6568
|
+
if (result.bullets.length) {
|
|
6569
|
+
entries += appendEntriesAt(temp, result.bullets, Date.now(), excerpt.next);
|
|
6570
|
+
}
|
|
6571
|
+
cursor = excerpt.next;
|
|
6572
|
+
}
|
|
6573
|
+
if (abort?.aborted) {
|
|
6574
|
+
try {
|
|
6575
|
+
rmSync3(temp, { force: true });
|
|
6576
|
+
} catch {
|
|
6577
|
+
}
|
|
6578
|
+
return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
|
|
6579
|
+
}
|
|
6580
|
+
if (entries === 0) {
|
|
6581
|
+
try {
|
|
6582
|
+
rmSync3(temp, { force: true });
|
|
6583
|
+
} catch {
|
|
6584
|
+
}
|
|
6585
|
+
return "No entries generated; previous timeline kept.";
|
|
6586
|
+
}
|
|
6587
|
+
renameSync6(temp, targetFile);
|
|
6588
|
+
const state2 = sessions.get(id);
|
|
6589
|
+
if (state2) {
|
|
6590
|
+
state2.cursor = cursor;
|
|
6591
|
+
state2.failures = 0;
|
|
6592
|
+
state2.pending = false;
|
|
6593
|
+
}
|
|
6594
|
+
return `Rebuilt ${messages.length} messages into ${entries} entries with ${calls} summarizer calls.`;
|
|
6595
|
+
} catch (error) {
|
|
6596
|
+
try {
|
|
6597
|
+
rmSync3(temp, { force: true });
|
|
6598
|
+
} catch {
|
|
6599
|
+
}
|
|
6600
|
+
return `Reprocess failed: ${String(error)}. Previous timeline kept.`;
|
|
6601
|
+
} finally {
|
|
6602
|
+
reprocessing.delete(id);
|
|
6603
|
+
}
|
|
6604
|
+
}
|
|
6605
|
+
const timelineReprocess = tool2({
|
|
6606
|
+
description: "Rebuild the current session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
|
|
6607
|
+
args: {},
|
|
6608
|
+
execute: async (_args, ctx) => {
|
|
6609
|
+
const output = await reprocess(ctx.sessionID, ctx.abort);
|
|
6610
|
+
return { title: "Timeline reprocess", output };
|
|
6611
|
+
}
|
|
6612
|
+
});
|
|
6613
|
+
return {
|
|
6614
|
+
event: async ({ event }) => {
|
|
6615
|
+
try {
|
|
6616
|
+
switch (event.type) {
|
|
6617
|
+
case "message.updated": {
|
|
6618
|
+
const id = event.properties.info?.sessionID;
|
|
6619
|
+
if (!id) return;
|
|
6620
|
+
if (owned.has(id)) return;
|
|
6621
|
+
const role = event.properties.info?.role;
|
|
6622
|
+
if (role === "user" || role === "assistant") note(id);
|
|
6623
|
+
return;
|
|
6624
|
+
}
|
|
6625
|
+
case "message.part.updated": {
|
|
6626
|
+
const id = event.properties.part?.sessionID;
|
|
6627
|
+
if (!id) return;
|
|
6628
|
+
if (owned.has(id)) return;
|
|
6629
|
+
const type = event.properties.part?.type;
|
|
6630
|
+
if (type === "text" || type === "tool") note(id);
|
|
6631
|
+
return;
|
|
6632
|
+
}
|
|
6633
|
+
case "session.created": {
|
|
6634
|
+
const info = event.properties.info;
|
|
6635
|
+
if (!info) return;
|
|
6636
|
+
if (isInternalTitle(info.title)) owned.add(info.id);
|
|
6637
|
+
return;
|
|
6638
|
+
}
|
|
6639
|
+
case "session.deleted": {
|
|
6640
|
+
const id = event.properties.info?.id;
|
|
6641
|
+
if (!id) return;
|
|
6642
|
+
const state2 = sessions.get(id);
|
|
6643
|
+
if (state2?.timer) {
|
|
6644
|
+
clearTimeout(state2.timer);
|
|
6645
|
+
state2.timer = void 0;
|
|
6646
|
+
}
|
|
6647
|
+
sessions.delete(id);
|
|
6648
|
+
removeEntries(id);
|
|
6649
|
+
owned.delete(id);
|
|
6650
|
+
return;
|
|
6651
|
+
}
|
|
6652
|
+
default:
|
|
6653
|
+
return;
|
|
6654
|
+
}
|
|
6655
|
+
} catch (error) {
|
|
6656
|
+
debug("event failed", error);
|
|
6657
|
+
}
|
|
6658
|
+
},
|
|
6659
|
+
dispose: async () => {
|
|
6660
|
+
disposed = true;
|
|
6661
|
+
for (const state2 of sessions.values()) {
|
|
6662
|
+
if (state2.timer) {
|
|
6663
|
+
clearTimeout(state2.timer);
|
|
6664
|
+
state2.timer = void 0;
|
|
6665
|
+
}
|
|
6666
|
+
}
|
|
6667
|
+
reprocessing.clear();
|
|
6668
|
+
},
|
|
6669
|
+
tool: { timeline_reprocess: timelineReprocess }
|
|
6670
|
+
};
|
|
6671
|
+
}
|
|
6672
|
+
|
|
4989
6673
|
// src/index.ts
|
|
4990
6674
|
var inertHooks = {
|
|
4991
6675
|
config: async () => {
|
|
@@ -5021,13 +6705,15 @@ var LaCode = (async (input, options) => {
|
|
|
5021
6705
|
return void 0;
|
|
5022
6706
|
}
|
|
5023
6707
|
};
|
|
5024
|
-
const agentSet = buildAgentSet();
|
|
6708
|
+
const agentSet = buildAgentSet(resolveModels({ models: opts.models, modelsFile: opts.modelsFile }).slots.length);
|
|
5025
6709
|
const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
|
|
5026
6710
|
const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
|
|
5027
6711
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
5028
6712
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
6713
|
+
const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
|
|
5029
6714
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
5030
6715
|
const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
|
|
6716
|
+
const timeline = await settled("timeline", () => timelineHooks(input, options)) ?? inertHooks;
|
|
5031
6717
|
const config = async (cfg) => {
|
|
5032
6718
|
try {
|
|
5033
6719
|
const injected = applyAgents(cfg, agentSet);
|
|
@@ -5051,6 +6737,8 @@ var LaCode = (async (input, options) => {
|
|
|
5051
6737
|
autoupdate.event?.(payload).catch(() => {
|
|
5052
6738
|
}),
|
|
5053
6739
|
notifier.event?.(payload).catch(() => {
|
|
6740
|
+
}),
|
|
6741
|
+
timeline.event?.(payload).catch(() => {
|
|
5054
6742
|
})
|
|
5055
6743
|
]);
|
|
5056
6744
|
};
|
|
@@ -5061,15 +6749,17 @@ var LaCode = (async (input, options) => {
|
|
|
5061
6749
|
// only thing that routes task calls, so there is nothing to merge them with.
|
|
5062
6750
|
"tool.execute.before": pool["tool.execute.before"],
|
|
5063
6751
|
"tool.execute.after": pool["tool.execute.after"],
|
|
5064
|
-
tool: pool.tool,
|
|
6752
|
+
tool: { ...pool.tool ?? {}, ...timeline.tool ?? {} },
|
|
5065
6753
|
"command.execute.before": imagepaste["command.execute.before"],
|
|
5066
6754
|
dispose: async () => {
|
|
5067
6755
|
await pool.dispose?.();
|
|
5068
6756
|
await autoupdate.dispose?.();
|
|
5069
6757
|
await notifier.dispose?.();
|
|
5070
6758
|
await selfupdate.dispose?.();
|
|
6759
|
+
await feedback.dispose?.();
|
|
5071
6760
|
await omniroute.dispose?.();
|
|
5072
6761
|
await imagepaste.dispose?.();
|
|
6762
|
+
await timeline.dispose?.();
|
|
5073
6763
|
},
|
|
5074
6764
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
5075
6765
|
if (!Array.isArray(output?.system)) return;
|