@pedrocivita/tocket 2.6.3 → 2.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/dist/commands/doctor.cmd.js +3 -3
- package/dist/commands/doctor.cmd.js.map +1 -1
- package/dist/commands/handoff.cmd.js +51 -1
- package/dist/commands/handoff.cmd.js.map +1 -1
- package/dist/commands/init.cmd.js +3 -2
- package/dist/commands/init.cmd.js.map +1 -1
- package/dist/commands/packs.cmd.d.ts +2 -0
- package/dist/commands/packs.cmd.js +58 -0
- package/dist/commands/packs.cmd.js.map +1 -0
- package/dist/commands/suite.cmd.js +9 -8
- package/dist/commands/suite.cmd.js.map +1 -1
- package/dist/eval/triage-eval.js +2 -1
- package/dist/eval/triage-eval.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/templates/memory-bank.js +12 -0
- package/dist/templates/memory-bank.js.map +1 -1
- package/dist/tests/agents.test.js +15 -1
- package/dist/tests/agents.test.js.map +1 -1
- package/dist/tests/attention.test.d.ts +1 -0
- package/dist/tests/attention.test.js +268 -0
- package/dist/tests/attention.test.js.map +1 -0
- package/dist/tests/context.test.js +10 -1
- package/dist/tests/context.test.js.map +1 -1
- package/dist/tests/decide.test.js +5 -5
- package/dist/tests/decide.test.js.map +1 -1
- package/dist/tests/doctor.test.js +2 -1
- package/dist/tests/doctor.test.js.map +1 -1
- package/dist/tests/helpers.d.ts +2 -0
- package/dist/tests/helpers.js +5 -1
- package/dist/tests/helpers.js.map +1 -1
- package/dist/tests/helpers.test.js +8 -1
- package/dist/tests/helpers.test.js.map +1 -1
- package/dist/tests/init.test.js +51 -22
- package/dist/tests/init.test.js.map +1 -1
- package/dist/tests/memory-bank.test.js +5 -0
- package/dist/tests/memory-bank.test.js.map +1 -1
- package/dist/utils/agents.d.ts +2 -0
- package/dist/utils/agents.js +12 -2
- package/dist/utils/agents.js.map +1 -1
- package/dist/utils/attention.d.ts +155 -0
- package/dist/utils/attention.js +695 -0
- package/dist/utils/attention.js.map +1 -0
- package/dist/utils/context.d.ts +2 -0
- package/dist/utils/context.js +21 -0
- package/dist/utils/context.js.map +1 -1
- package/dist/utils/decide.d.ts +1 -1
- package/dist/utils/decide.js +2 -11
- package/dist/utils/decide.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/** Meta-attention: Jev (or the stub) scores context chunks and gotcha packs. */
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileTimestamp, isInsideContext, resolveDecideMode, resolveSemantics, slugId, STUB_MODEL, } from "./decide.js";
|
|
5
|
+
import { askJev, readTypesafeApiKey } from "./jev.js";
|
|
6
|
+
export const ATTENTION_SCHEMA = "tocket.attention/v0";
|
|
7
|
+
export const ATTENTION_DIR = ".context/attention";
|
|
8
|
+
export const HANDOFFS_DIR = ".context/handoffs";
|
|
9
|
+
export const GOTCHAS_DIR = ".context/gotchas";
|
|
10
|
+
export const ACTIVE_PACKS_REL = ".context/active/packs.md";
|
|
11
|
+
export const DEFAULT_THRESHOLD = 0.5;
|
|
12
|
+
export const SCORE_MIN = 0;
|
|
13
|
+
export const SCORE_MAX = 1;
|
|
14
|
+
export const MAX_CHUNK_CHARS = 700;
|
|
15
|
+
export const DECISION_CHUNK_LIMIT = 5;
|
|
16
|
+
const CONTEXT_FILES = [
|
|
17
|
+
"activeContext.md",
|
|
18
|
+
"systemPatterns.md",
|
|
19
|
+
"techContext.md",
|
|
20
|
+
"productContext.md",
|
|
21
|
+
"progress.md",
|
|
22
|
+
];
|
|
23
|
+
const STOP_WORDS = new Set([
|
|
24
|
+
"the",
|
|
25
|
+
"and",
|
|
26
|
+
"for",
|
|
27
|
+
"with",
|
|
28
|
+
"this",
|
|
29
|
+
"that",
|
|
30
|
+
"from",
|
|
31
|
+
"into",
|
|
32
|
+
"your",
|
|
33
|
+
"are",
|
|
34
|
+
"was",
|
|
35
|
+
"were",
|
|
36
|
+
"has",
|
|
37
|
+
"have",
|
|
38
|
+
"not",
|
|
39
|
+
"but",
|
|
40
|
+
"you",
|
|
41
|
+
"our",
|
|
42
|
+
"its",
|
|
43
|
+
"per",
|
|
44
|
+
"via",
|
|
45
|
+
"any",
|
|
46
|
+
]);
|
|
47
|
+
export class AttentionError extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "AttentionError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function parseThreshold(value) {
|
|
54
|
+
if (value === undefined || value === "")
|
|
55
|
+
return DEFAULT_THRESHOLD;
|
|
56
|
+
const n = Number(value);
|
|
57
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
58
|
+
throw new AttentionError("Threshold must be between 0 and 1.");
|
|
59
|
+
}
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
/** Kept when Noul >= threshold OR Score >= the same fraction of the score range (mid at 0.5). */
|
|
63
|
+
export function chunkKept(noul, score, threshold, scoreMin = SCORE_MIN, scoreMax = SCORE_MAX) {
|
|
64
|
+
const scoreCut = scoreMin + threshold * (scoreMax - scoreMin);
|
|
65
|
+
return noul >= threshold || score >= scoreCut;
|
|
66
|
+
}
|
|
67
|
+
export function relevanceTokens(text) {
|
|
68
|
+
return text
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.split(/[^a-z0-9]+/)
|
|
71
|
+
.filter((word) => word.length >= 3 && !STOP_WORDS.has(word));
|
|
72
|
+
}
|
|
73
|
+
/** Deterministic overlap. No network. Unrelated chunks stay under the default 0.5 cutoff. */
|
|
74
|
+
export function stubRelevance(query, chunkText) {
|
|
75
|
+
const queryTokens = [...new Set(relevanceTokens(query))];
|
|
76
|
+
if (queryTokens.length === 0) {
|
|
77
|
+
return { noul: 0.5, score: 0.5, rationale: "Empty query; neutral stub." };
|
|
78
|
+
}
|
|
79
|
+
const body = chunkText.toLowerCase();
|
|
80
|
+
const hits = queryTokens.filter((token) => body.includes(token));
|
|
81
|
+
const ratio = hits.length / queryTokens.length;
|
|
82
|
+
let noul = 0.08;
|
|
83
|
+
if (hits.length === 0)
|
|
84
|
+
noul = 0.08;
|
|
85
|
+
else if (ratio < 0.34)
|
|
86
|
+
noul = 0.22;
|
|
87
|
+
else if (ratio < 0.67)
|
|
88
|
+
noul = 0.64;
|
|
89
|
+
else
|
|
90
|
+
noul = 0.88;
|
|
91
|
+
return {
|
|
92
|
+
noul,
|
|
93
|
+
score: Number(noul.toFixed(4)),
|
|
94
|
+
rationale: hits.length === 0
|
|
95
|
+
? "No query tokens in chunk."
|
|
96
|
+
: `Stub overlap ${hits.length}/${queryTokens.length} (${hits.join(", ")}).`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function maskFences(text) {
|
|
100
|
+
return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
|
|
101
|
+
}
|
|
102
|
+
function cleanBody(body) {
|
|
103
|
+
return body
|
|
104
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
105
|
+
.replace(/^\s*---\s*$/gm, "")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
108
|
+
function fileStem(source) {
|
|
109
|
+
const base = source.split(/[\\/]/).pop() ?? source;
|
|
110
|
+
return base.replace(/\.md$/i, "") || source;
|
|
111
|
+
}
|
|
112
|
+
function uniqueId(base, used) {
|
|
113
|
+
const root = slugId(base);
|
|
114
|
+
if (!used.has(root)) {
|
|
115
|
+
used.add(root);
|
|
116
|
+
return root;
|
|
117
|
+
}
|
|
118
|
+
let n = 2;
|
|
119
|
+
while (used.has(`${root}-${n}`))
|
|
120
|
+
n += 1;
|
|
121
|
+
const id = `${root}-${n}`;
|
|
122
|
+
used.add(id);
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
function splitLong(source, label, heading, body, maxChars, used, intent) {
|
|
126
|
+
const trimmed = cleanBody(body);
|
|
127
|
+
if (!trimmed)
|
|
128
|
+
return [];
|
|
129
|
+
const parts = [];
|
|
130
|
+
if (trimmed.length <= maxChars) {
|
|
131
|
+
parts.push(trimmed);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const paras = trimmed.split(/\n{2,}/);
|
|
135
|
+
let buf = "";
|
|
136
|
+
const flush = () => {
|
|
137
|
+
if (buf.trim())
|
|
138
|
+
parts.push(buf.trim());
|
|
139
|
+
buf = "";
|
|
140
|
+
};
|
|
141
|
+
for (const para of paras) {
|
|
142
|
+
if (para.length > maxChars) {
|
|
143
|
+
flush();
|
|
144
|
+
for (let i = 0; i < para.length; i += maxChars) {
|
|
145
|
+
const slice = para.slice(i, i + maxChars).trim();
|
|
146
|
+
if (slice)
|
|
147
|
+
parts.push(slice);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (buf && buf.length + 2 + para.length > maxChars)
|
|
152
|
+
flush();
|
|
153
|
+
buf = buf ? `${buf}\n\n${para}` : para;
|
|
154
|
+
}
|
|
155
|
+
flush();
|
|
156
|
+
}
|
|
157
|
+
return parts.map((text, index) => {
|
|
158
|
+
const partLabel = parts.length > 1 ? `${label} (${index + 1})` : label;
|
|
159
|
+
return {
|
|
160
|
+
id: uniqueId(`${source} ${partLabel}`, used),
|
|
161
|
+
label: partLabel,
|
|
162
|
+
source,
|
|
163
|
+
heading: parts.length > 1 ? `${heading} (${index + 1})` : heading,
|
|
164
|
+
text,
|
|
165
|
+
intent,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Split a markdown file into small labeled chunks (## / ###, then paragraphs). */
|
|
170
|
+
export function splitMarkdown(source, markdown, intent = "relevant", maxChars = MAX_CHUNK_CHARS) {
|
|
171
|
+
const text = markdown.replace(/\r\n/g, "\n");
|
|
172
|
+
const masked = maskFences(text);
|
|
173
|
+
const used = new Set();
|
|
174
|
+
const h2 = [...masked.matchAll(/^##[ \t]+(.+)$/gm)];
|
|
175
|
+
if (h2.length === 0) {
|
|
176
|
+
const withoutTitle = text.replace(/^#[ \t]+[^\n]*\n+/, "");
|
|
177
|
+
const heading = fileStem(source);
|
|
178
|
+
return splitLong(source, heading, heading, withoutTitle, maxChars, used, intent);
|
|
179
|
+
}
|
|
180
|
+
const chunks = [];
|
|
181
|
+
for (let i = 0; i < h2.length; i++) {
|
|
182
|
+
const heading = h2[i][1].trim();
|
|
183
|
+
const start = (h2[i].index ?? 0) + h2[i][0].length;
|
|
184
|
+
const end = i + 1 < h2.length ? (h2[i + 1].index ?? text.length) : text.length;
|
|
185
|
+
const section = text.slice(start, end);
|
|
186
|
+
const sectionMasked = masked.slice(start, end);
|
|
187
|
+
const h3 = [...sectionMasked.matchAll(/^###[ \t]+(.+)$/gm)];
|
|
188
|
+
if (h3.length === 0) {
|
|
189
|
+
chunks.push(...splitLong(source, heading, heading, section, maxChars, used, intent));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const intro = section.slice(0, h3[0].index ?? 0);
|
|
193
|
+
chunks.push(...splitLong(source, heading, heading, intro, maxChars, used, intent));
|
|
194
|
+
for (let j = 0; j < h3.length; j++) {
|
|
195
|
+
const sub = h3[j][1].trim();
|
|
196
|
+
const subStart = (h3[j].index ?? 0) + h3[j][0].length;
|
|
197
|
+
const subEnd = j + 1 < h3.length ? (h3[j + 1].index ?? section.length) : section.length;
|
|
198
|
+
const label = `${heading} / ${sub}`;
|
|
199
|
+
chunks.push(...splitLong(source, label, sub, section.slice(subStart, subEnd), maxChars, used, intent));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return chunks;
|
|
203
|
+
}
|
|
204
|
+
function isRecord(value) {
|
|
205
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
206
|
+
}
|
|
207
|
+
function collectDecisionChunks(cwd) {
|
|
208
|
+
const dir = join(cwd, ".context", "decisions");
|
|
209
|
+
if (!existsSync(dir))
|
|
210
|
+
return [];
|
|
211
|
+
const files = [];
|
|
212
|
+
const walk = (current) => {
|
|
213
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
214
|
+
const abs = join(current, entry.name);
|
|
215
|
+
if (entry.isDirectory()) {
|
|
216
|
+
walk(abs);
|
|
217
|
+
}
|
|
218
|
+
else if (entry.isFile() &&
|
|
219
|
+
entry.name.endsWith(".json") &&
|
|
220
|
+
!entry.name.endsWith(".applied.json")) {
|
|
221
|
+
files.push(abs);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
walk(dir);
|
|
226
|
+
const recent = files
|
|
227
|
+
.map((abs) => ({ abs, mtime: statSync(abs).mtimeMs }))
|
|
228
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
229
|
+
.slice(0, DECISION_CHUNK_LIMIT);
|
|
230
|
+
const used = new Set();
|
|
231
|
+
const chunks = [];
|
|
232
|
+
for (const { abs } of recent) {
|
|
233
|
+
let value;
|
|
234
|
+
try {
|
|
235
|
+
value = JSON.parse(readFileSync(abs, "utf-8"));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!isRecord(value))
|
|
241
|
+
continue;
|
|
242
|
+
const id = typeof value.id === "string" ? value.id : fileStem(abs);
|
|
243
|
+
const destination = typeof value.destination === "string" ? value.destination : "";
|
|
244
|
+
const choice = typeof value.choice === "string" ? value.choice : "";
|
|
245
|
+
const toolGate = typeof value.tool_gate === "string" ? value.tool_gate : "";
|
|
246
|
+
const state = typeof value.state === "string" ? value.state : JSON.stringify(value.state ?? "");
|
|
247
|
+
const lines = [
|
|
248
|
+
`Decision ${id}`,
|
|
249
|
+
destination ? `destination=${destination}` : "",
|
|
250
|
+
choice ? `choice=${choice}` : "",
|
|
251
|
+
toolGate ? `tool_gate=${toolGate}` : "",
|
|
252
|
+
state ? `state=${state.slice(0, 240)}` : "",
|
|
253
|
+
].filter(Boolean);
|
|
254
|
+
const source = abs.startsWith(cwd) ? abs.slice(cwd.length).replace(/^[\\/]/, "") : abs;
|
|
255
|
+
const posix = source.split(/[\\/]/g).join("/");
|
|
256
|
+
const label = `decisions / ${id}`;
|
|
257
|
+
chunks.push({
|
|
258
|
+
id: uniqueId(`${posix} ${label}`, used),
|
|
259
|
+
label,
|
|
260
|
+
source: posix,
|
|
261
|
+
heading: id,
|
|
262
|
+
text: lines.join("\n"),
|
|
263
|
+
intent: "relevant",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return chunks;
|
|
267
|
+
}
|
|
268
|
+
export function collectContextChunks(cwd) {
|
|
269
|
+
const chunks = [];
|
|
270
|
+
for (const name of CONTEXT_FILES) {
|
|
271
|
+
const abs = join(cwd, ".context", name);
|
|
272
|
+
if (!existsSync(abs))
|
|
273
|
+
continue;
|
|
274
|
+
chunks.push(...splitMarkdown(`.context/${name}`, readFileSync(abs, "utf-8"), "relevant"));
|
|
275
|
+
}
|
|
276
|
+
chunks.push(...collectDecisionChunks(cwd));
|
|
277
|
+
return chunks;
|
|
278
|
+
}
|
|
279
|
+
export function collectGotchaPacks(cwd) {
|
|
280
|
+
const dir = join(cwd, ".context", "gotchas");
|
|
281
|
+
if (!existsSync(dir))
|
|
282
|
+
return [];
|
|
283
|
+
const names = readdirSync(dir)
|
|
284
|
+
.filter((name) => name.endsWith(".md") && name.toLowerCase() !== "readme.md")
|
|
285
|
+
.sort();
|
|
286
|
+
const chunks = [];
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const source = `.context/gotchas/${name}`;
|
|
289
|
+
chunks.push(...splitMarkdown(source, readFileSync(join(dir, name), "utf-8"), "load"));
|
|
290
|
+
}
|
|
291
|
+
return chunks;
|
|
292
|
+
}
|
|
293
|
+
export function queryFromFocus(content) {
|
|
294
|
+
const match = content.match(/## Current Focus\s*\n+(.+)/);
|
|
295
|
+
if (!match?.[1])
|
|
296
|
+
return "";
|
|
297
|
+
const plain = match[1].replace(/\*\*/g, "").trim();
|
|
298
|
+
if (!plain || plain.startsWith("_") || plain.includes("No active tasks"))
|
|
299
|
+
return "";
|
|
300
|
+
return plain;
|
|
301
|
+
}
|
|
302
|
+
function scoreChunk(query, chunk, threshold) {
|
|
303
|
+
const stub = stubRelevance(query, `${chunk.label}\n${chunk.text}`);
|
|
304
|
+
return {
|
|
305
|
+
...chunk,
|
|
306
|
+
noul: stub.noul,
|
|
307
|
+
score: stub.score,
|
|
308
|
+
kept: chunkKept(stub.noul, stub.score, threshold),
|
|
309
|
+
rationale: stub.rationale,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function questionsFor(query, batch) {
|
|
313
|
+
const questions = {};
|
|
314
|
+
batch.forEach((chunk, index) => {
|
|
315
|
+
const excerpt = chunk.text.slice(0, 600);
|
|
316
|
+
const lead = chunk.intent === "load"
|
|
317
|
+
? "Should this gotcha pack be loaded for the query?"
|
|
318
|
+
: "Is this context chunk relevant to the query?";
|
|
319
|
+
questions[`c${index}_relevant`] = {
|
|
320
|
+
type: "noul",
|
|
321
|
+
instructions: `Query: ${query}\n${lead}\nLabel: ${chunk.label}\n---\n${excerpt}`,
|
|
322
|
+
criteria: {
|
|
323
|
+
true: "The chunk should be included for this query.",
|
|
324
|
+
false: "The chunk is unrelated to the query.",
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
questions[`c${index}_relevance`] = {
|
|
328
|
+
type: "score",
|
|
329
|
+
min: SCORE_MIN,
|
|
330
|
+
max: SCORE_MAX,
|
|
331
|
+
instructions: `Query: ${query}\nScore relevance of "${chunk.label}" from 0 to 1.\n---\n${excerpt}`,
|
|
332
|
+
criteria: {
|
|
333
|
+
low: "Unrelated to the query.",
|
|
334
|
+
high: "Directly useful for the query.",
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
return questions;
|
|
339
|
+
}
|
|
340
|
+
export async function judgeChunks(options) {
|
|
341
|
+
const dryRun = options.dryRun === true;
|
|
342
|
+
const apiKey = dryRun
|
|
343
|
+
? undefined
|
|
344
|
+
: options.apiKey === null
|
|
345
|
+
? undefined
|
|
346
|
+
: options.apiKey ?? readTypesafeApiKey();
|
|
347
|
+
const { useLive, mode } = resolveDecideMode({
|
|
348
|
+
dryRun,
|
|
349
|
+
shadow: options.shadow === true,
|
|
350
|
+
apiKey,
|
|
351
|
+
});
|
|
352
|
+
const { semantics } = resolveSemantics(mode);
|
|
353
|
+
const stubbed = () => options.chunks.map((chunk) => scoreChunk(options.query, chunk, options.threshold));
|
|
354
|
+
if (!useLive || !apiKey) {
|
|
355
|
+
return {
|
|
356
|
+
chunks: stubbed(),
|
|
357
|
+
source: "stub",
|
|
358
|
+
mode,
|
|
359
|
+
semantics,
|
|
360
|
+
model: STUB_MODEL,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const scored = [];
|
|
365
|
+
let model = STUB_MODEL;
|
|
366
|
+
const batchSize = 8;
|
|
367
|
+
for (let offset = 0; offset < options.chunks.length; offset += batchSize) {
|
|
368
|
+
const batch = options.chunks.slice(offset, offset + batchSize);
|
|
369
|
+
const live = await askJev({
|
|
370
|
+
apiKey,
|
|
371
|
+
fetchImpl: options.fetchImpl,
|
|
372
|
+
state: {
|
|
373
|
+
query: options.query,
|
|
374
|
+
threshold: options.threshold,
|
|
375
|
+
chunks: batch.map((chunk) => ({
|
|
376
|
+
id: chunk.id,
|
|
377
|
+
label: chunk.label,
|
|
378
|
+
source: chunk.source,
|
|
379
|
+
intent: chunk.intent,
|
|
380
|
+
})),
|
|
381
|
+
},
|
|
382
|
+
questions: questionsFor(options.query, batch),
|
|
383
|
+
});
|
|
384
|
+
model = live.model || model;
|
|
385
|
+
batch.forEach((chunk, index) => {
|
|
386
|
+
const noulAnswer = live.answers[`c${index}_relevant`];
|
|
387
|
+
const scoreAnswer = live.answers[`c${index}_relevance`];
|
|
388
|
+
const noul = noulAnswer && noulAnswer.type === "noul" ? noulAnswer.noul : 0;
|
|
389
|
+
const score = scoreAnswer && scoreAnswer.type === "score" ? scoreAnswer.score : 0;
|
|
390
|
+
scored.push({
|
|
391
|
+
...chunk,
|
|
392
|
+
noul,
|
|
393
|
+
score,
|
|
394
|
+
kept: chunkKept(noul, score, options.threshold),
|
|
395
|
+
rationale: `Jev noul=${noul.toFixed(2)} score=${score.toFixed(2)}.`,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return { chunks: scored, source: "jev", mode, semantics, model };
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return {
|
|
403
|
+
chunks: stubbed(),
|
|
404
|
+
source: "stub",
|
|
405
|
+
mode,
|
|
406
|
+
semantics,
|
|
407
|
+
model: STUB_MODEL,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function preview(text) {
|
|
412
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
413
|
+
}
|
|
414
|
+
function toReceipt(chunk) {
|
|
415
|
+
return {
|
|
416
|
+
id: chunk.id,
|
|
417
|
+
label: chunk.label,
|
|
418
|
+
source: chunk.source,
|
|
419
|
+
intent: chunk.intent,
|
|
420
|
+
noul: chunk.noul,
|
|
421
|
+
score: chunk.score,
|
|
422
|
+
kept: chunk.kept,
|
|
423
|
+
rationale: chunk.rationale,
|
|
424
|
+
preview: preview(chunk.text),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
export function attentionReadme() {
|
|
428
|
+
return `# Attention
|
|
429
|
+
|
|
430
|
+
Meta-attention receipts from \`tocket handoff --aware\` and \`tocket packs load\`.
|
|
431
|
+
|
|
432
|
+
Jev (or the stub) scores each chunk. Workers read the filtered handoff and \`.context/active/packs.md\`. This folder is the judge's receipt. Tocket does not run workers.
|
|
433
|
+
|
|
434
|
+
\`--dry-run\` or no \`TYPESAFE_API_KEY\`: stub. \`--shadow\` with a key: live Jev, \`semantics: log-only\`.
|
|
435
|
+
`;
|
|
436
|
+
}
|
|
437
|
+
function ensureAttentionDir(cwd) {
|
|
438
|
+
const dir = resolve(cwd, ATTENTION_DIR);
|
|
439
|
+
if (!isInsideContext(cwd, dir)) {
|
|
440
|
+
throw new AttentionError("attention writes only under .context/");
|
|
441
|
+
}
|
|
442
|
+
mkdirSync(dir, { recursive: true });
|
|
443
|
+
const readmePath = join(dir, "README.md");
|
|
444
|
+
if (!existsSync(readmePath)) {
|
|
445
|
+
writeFileSync(readmePath, attentionReadme(), "utf-8");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
export function attentionOutputPath(cwd, generatedAt, id) {
|
|
449
|
+
const file = `${fileTimestamp(generatedAt)}-${slugId(id)}.json`;
|
|
450
|
+
const outPath = resolve(cwd, ATTENTION_DIR, file);
|
|
451
|
+
if (!isInsideContext(cwd, outPath)) {
|
|
452
|
+
throw new AttentionError("attention writes only under .context/");
|
|
453
|
+
}
|
|
454
|
+
return outPath;
|
|
455
|
+
}
|
|
456
|
+
export function handoffOutputPath(cwd, generatedAt, id) {
|
|
457
|
+
const file = `${fileTimestamp(generatedAt)}-${slugId(id)}.md`;
|
|
458
|
+
const outPath = resolve(cwd, HANDOFFS_DIR, file);
|
|
459
|
+
if (!isInsideContext(cwd, outPath)) {
|
|
460
|
+
throw new AttentionError("handoff writes only under .context/");
|
|
461
|
+
}
|
|
462
|
+
return outPath;
|
|
463
|
+
}
|
|
464
|
+
export function activePacksPath(cwd) {
|
|
465
|
+
const outPath = resolve(cwd, ACTIVE_PACKS_REL);
|
|
466
|
+
if (!isInsideContext(cwd, outPath)) {
|
|
467
|
+
throw new AttentionError("packs write only under .context/");
|
|
468
|
+
}
|
|
469
|
+
return outPath;
|
|
470
|
+
}
|
|
471
|
+
function keptBlocks(chunks) {
|
|
472
|
+
const lines = [];
|
|
473
|
+
for (const chunk of chunks.filter((item) => item.kept)) {
|
|
474
|
+
lines.push(`### ${chunk.label}`, "", chunk.text, "");
|
|
475
|
+
}
|
|
476
|
+
return lines;
|
|
477
|
+
}
|
|
478
|
+
export function buildAwareHandoffMarkdown(input) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
const kept = input.context.filter((chunk) => chunk.kept);
|
|
481
|
+
lines.push(`## Session Handoff — ${input.projectName}`);
|
|
482
|
+
lines.push(`**Query:** ${input.query}`);
|
|
483
|
+
if (input.focus)
|
|
484
|
+
lines.push(`**Focus:** ${input.focus}`);
|
|
485
|
+
if (input.branch)
|
|
486
|
+
lines.push(`**Branch:** ${input.branch}`);
|
|
487
|
+
lines.push(`**Aware:** kept ${kept.length}/${input.context.length} chunks (threshold ${input.threshold.toFixed(2)}, ${input.source}, ${input.mode})`);
|
|
488
|
+
lines.push("");
|
|
489
|
+
if (input.recentCommits.length > 0) {
|
|
490
|
+
lines.push(`### Recent Changes (last ${input.recentCommits.length} commits)`);
|
|
491
|
+
for (const commit of input.recentCommits)
|
|
492
|
+
lines.push(`- ${commit}`);
|
|
493
|
+
lines.push("");
|
|
494
|
+
}
|
|
495
|
+
if (input.modifiedFiles.length > 0) {
|
|
496
|
+
lines.push("### Modified Files (since last sync)");
|
|
497
|
+
for (const file of input.modifiedFiles)
|
|
498
|
+
lines.push(`- ${file}`);
|
|
499
|
+
lines.push("");
|
|
500
|
+
}
|
|
501
|
+
if (kept.length === 0) {
|
|
502
|
+
lines.push("_No context chunk passed the threshold._", "");
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
lines.push(...keptBlocks(input.context));
|
|
506
|
+
}
|
|
507
|
+
const omitted = input.context.filter((chunk) => !chunk.kept).map((chunk) => chunk.label);
|
|
508
|
+
if (omitted.length > 0) {
|
|
509
|
+
lines.push("### Omitted chunks", "");
|
|
510
|
+
for (const label of omitted)
|
|
511
|
+
lines.push(`- ${label}`);
|
|
512
|
+
lines.push("");
|
|
513
|
+
}
|
|
514
|
+
if (input.packs.length > 0) {
|
|
515
|
+
const keptPacks = input.packs.filter((chunk) => chunk.kept);
|
|
516
|
+
lines.push("### Gotcha packs", "");
|
|
517
|
+
if (keptPacks.length === 0) {
|
|
518
|
+
lines.push("_No gotcha pack passed the threshold._", "");
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
for (const pack of keptPacks) {
|
|
522
|
+
lines.push(`#### ${pack.label}`, "", pack.text, "");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const omittedPacks = input.packs.filter((chunk) => !chunk.kept).map((chunk) => chunk.label);
|
|
526
|
+
if (omittedPacks.length > 0) {
|
|
527
|
+
lines.push("Omitted packs:");
|
|
528
|
+
for (const label of omittedPacks)
|
|
529
|
+
lines.push(`- ${label}`);
|
|
530
|
+
lines.push("");
|
|
531
|
+
}
|
|
532
|
+
lines.push("Overlay: `tocket packs load --query` writes `.context/active/packs.md`.", "");
|
|
533
|
+
}
|
|
534
|
+
lines.push(`Receipt: \`${input.attentionRel}\``);
|
|
535
|
+
lines.push("");
|
|
536
|
+
return lines.join("\n");
|
|
537
|
+
}
|
|
538
|
+
export function buildPacksMarkdown(input) {
|
|
539
|
+
const kept = input.packs.filter((chunk) => chunk.kept);
|
|
540
|
+
const lines = [];
|
|
541
|
+
lines.push("# Active packs", "");
|
|
542
|
+
lines.push(`**Query:** ${input.query}`);
|
|
543
|
+
lines.push(`**Loaded:** ${kept.length} of ${input.packs.length} (threshold ${input.threshold.toFixed(2)}, ${input.source}, ${input.mode})`);
|
|
544
|
+
lines.push("");
|
|
545
|
+
if (input.packs.length === 0) {
|
|
546
|
+
lines.push("No packs under `.context/gotchas/`. Add markdown files such as `frontend.md` or `path-src-api.md`.", "");
|
|
547
|
+
}
|
|
548
|
+
else if (kept.length === 0) {
|
|
549
|
+
lines.push("_No pack passed the threshold._", "");
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
for (const pack of kept) {
|
|
553
|
+
lines.push(`## ${pack.label}`, "", pack.text, "");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const omitted = input.packs.filter((chunk) => !chunk.kept).map((chunk) => chunk.label);
|
|
557
|
+
if (omitted.length > 0) {
|
|
558
|
+
lines.push("Omitted:");
|
|
559
|
+
for (const label of omitted)
|
|
560
|
+
lines.push(`- ${label}`);
|
|
561
|
+
lines.push("");
|
|
562
|
+
}
|
|
563
|
+
lines.push(`Receipt: \`${input.attentionRel}\``, "");
|
|
564
|
+
return lines.join("\n");
|
|
565
|
+
}
|
|
566
|
+
export function formatAwareSummary(record, handoffRel, attentionRel) {
|
|
567
|
+
const kept = record.chunks.filter((chunk) => chunk.kept).length;
|
|
568
|
+
const packs = record.packs ?? [];
|
|
569
|
+
const packBit = packs.length > 0 ? ` packs=${packs.filter((chunk) => chunk.kept).length}/${packs.length}` : "";
|
|
570
|
+
return `handoff aware kept=${kept}/${record.chunks.length}${packBit} threshold=${record.threshold.toFixed(2)} source=${record.source} mode=${record.mode} ${handoffRel} ${attentionRel}`;
|
|
571
|
+
}
|
|
572
|
+
export function formatPacksSummary(record, packsRel) {
|
|
573
|
+
const kept = record.chunks.filter((chunk) => chunk.kept).length;
|
|
574
|
+
return `packs load kept=${kept}/${record.chunks.length} threshold=${record.threshold.toFixed(2)} source=${record.source} mode=${record.mode} ${packsRel}`;
|
|
575
|
+
}
|
|
576
|
+
function writeAttention(cwd, record, outPath) {
|
|
577
|
+
ensureAttentionDir(cwd);
|
|
578
|
+
if (!isInsideContext(cwd, outPath)) {
|
|
579
|
+
throw new AttentionError("attention writes only under .context/");
|
|
580
|
+
}
|
|
581
|
+
writeFileSync(outPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
582
|
+
}
|
|
583
|
+
export async function runAwareHandoff(options) {
|
|
584
|
+
const focus = options.focus?.trim() ?? "";
|
|
585
|
+
const query = options.query?.trim() || focus;
|
|
586
|
+
if (!query) {
|
|
587
|
+
throw new AttentionError("Pass --query or set Current Focus in activeContext.md.");
|
|
588
|
+
}
|
|
589
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
590
|
+
const context = collectContextChunks(options.cwd);
|
|
591
|
+
const packs = collectGotchaPacks(options.cwd);
|
|
592
|
+
const judged = await judgeChunks({
|
|
593
|
+
query,
|
|
594
|
+
chunks: [...context, ...packs],
|
|
595
|
+
threshold,
|
|
596
|
+
dryRun: options.dryRun,
|
|
597
|
+
shadow: options.shadow,
|
|
598
|
+
apiKey: options.apiKey,
|
|
599
|
+
fetchImpl: options.fetchImpl,
|
|
600
|
+
});
|
|
601
|
+
const contextIds = new Set(context.map((chunk) => chunk.id));
|
|
602
|
+
const scoredContext = judged.chunks.filter((chunk) => contextIds.has(chunk.id));
|
|
603
|
+
const scoredPacks = judged.chunks.filter((chunk) => !contextIds.has(chunk.id));
|
|
604
|
+
const generatedAt = options.now ? options.now() : new Date().toISOString();
|
|
605
|
+
const id = options.id ?? "handoff";
|
|
606
|
+
const attentionPath = attentionOutputPath(options.cwd, generatedAt, id);
|
|
607
|
+
const handoffPath = handoffOutputPath(options.cwd, generatedAt, id);
|
|
608
|
+
const attentionRel = attentionPath.slice(options.cwd.length).replace(/^[\\/]/, "").split(/[\\/]/g).join("/");
|
|
609
|
+
const record = {
|
|
610
|
+
schema: ATTENTION_SCHEMA,
|
|
611
|
+
id: slugId(id),
|
|
612
|
+
generated_at: generatedAt,
|
|
613
|
+
query,
|
|
614
|
+
threshold,
|
|
615
|
+
source: judged.source,
|
|
616
|
+
mode: judged.mode,
|
|
617
|
+
semantics: judged.semantics,
|
|
618
|
+
model: judged.model,
|
|
619
|
+
executes: false,
|
|
620
|
+
kind: "handoff",
|
|
621
|
+
chunks: scoredContext.map(toReceipt),
|
|
622
|
+
packs: scoredPacks.map(toReceipt),
|
|
623
|
+
};
|
|
624
|
+
const markdown = buildAwareHandoffMarkdown({
|
|
625
|
+
projectName: options.projectName?.trim() || "Project",
|
|
626
|
+
query,
|
|
627
|
+
focus,
|
|
628
|
+
branch: options.branch ?? "",
|
|
629
|
+
recentCommits: options.recentCommits ?? [],
|
|
630
|
+
modifiedFiles: options.modifiedFiles ?? [],
|
|
631
|
+
context: scoredContext,
|
|
632
|
+
packs: scoredPacks,
|
|
633
|
+
threshold,
|
|
634
|
+
source: judged.source,
|
|
635
|
+
mode: judged.mode,
|
|
636
|
+
attentionRel,
|
|
637
|
+
});
|
|
638
|
+
writeAttention(options.cwd, record, attentionPath);
|
|
639
|
+
mkdirSync(resolve(options.cwd, HANDOFFS_DIR), { recursive: true });
|
|
640
|
+
if (!isInsideContext(options.cwd, handoffPath)) {
|
|
641
|
+
throw new AttentionError("handoff writes only under .context/");
|
|
642
|
+
}
|
|
643
|
+
writeFileSync(handoffPath, markdown, "utf-8");
|
|
644
|
+
return { markdown, record, attentionPath, handoffPath };
|
|
645
|
+
}
|
|
646
|
+
export async function runPacksLoad(options) {
|
|
647
|
+
const focus = options.focus?.trim() ?? "";
|
|
648
|
+
const query = options.query?.trim() || focus;
|
|
649
|
+
if (!query) {
|
|
650
|
+
throw new AttentionError("Pass --query or set Current Focus in activeContext.md.");
|
|
651
|
+
}
|
|
652
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
653
|
+
const packs = collectGotchaPacks(options.cwd);
|
|
654
|
+
const judged = await judgeChunks({
|
|
655
|
+
query,
|
|
656
|
+
chunks: packs,
|
|
657
|
+
threshold,
|
|
658
|
+
dryRun: options.dryRun,
|
|
659
|
+
shadow: options.shadow,
|
|
660
|
+
apiKey: options.apiKey,
|
|
661
|
+
fetchImpl: options.fetchImpl,
|
|
662
|
+
});
|
|
663
|
+
const generatedAt = options.now ? options.now() : new Date().toISOString();
|
|
664
|
+
const id = options.id ?? "packs";
|
|
665
|
+
const attentionPath = attentionOutputPath(options.cwd, generatedAt, id);
|
|
666
|
+
const packsPath = activePacksPath(options.cwd);
|
|
667
|
+
const attentionRel = attentionPath.slice(options.cwd.length).replace(/^[\\/]/, "").split(/[\\/]/g).join("/");
|
|
668
|
+
const record = {
|
|
669
|
+
schema: ATTENTION_SCHEMA,
|
|
670
|
+
id: slugId(id),
|
|
671
|
+
generated_at: generatedAt,
|
|
672
|
+
query,
|
|
673
|
+
threshold,
|
|
674
|
+
source: judged.source,
|
|
675
|
+
mode: judged.mode,
|
|
676
|
+
semantics: judged.semantics,
|
|
677
|
+
model: judged.model,
|
|
678
|
+
executes: false,
|
|
679
|
+
kind: "packs",
|
|
680
|
+
chunks: judged.chunks.map(toReceipt),
|
|
681
|
+
};
|
|
682
|
+
const markdown = buildPacksMarkdown({
|
|
683
|
+
query,
|
|
684
|
+
packs: judged.chunks,
|
|
685
|
+
threshold,
|
|
686
|
+
source: judged.source,
|
|
687
|
+
mode: judged.mode,
|
|
688
|
+
attentionRel,
|
|
689
|
+
});
|
|
690
|
+
writeAttention(options.cwd, record, attentionPath);
|
|
691
|
+
mkdirSync(resolve(options.cwd, ".context", "active"), { recursive: true });
|
|
692
|
+
writeFileSync(packsPath, markdown, "utf-8");
|
|
693
|
+
return { markdown, record, attentionPath, packsPath };
|
|
694
|
+
}
|
|
695
|
+
//# sourceMappingURL=attention.js.map
|