@xiaolei.shawn/mcp-server 0.2.1 → 0.3.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.
@@ -2,7 +2,7 @@
2
2
  * Ingest and merge logic tests.
3
3
  * Run from mcp-server: pnpm run build && pnpm test
4
4
  */
5
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import { describe, it, before, after } from "node:test";
@@ -102,4 +102,43 @@ describe("ingest", () => {
102
102
  assert.strictEqual(result.adapter, "codex_jsonl");
103
103
  assert.ok(result.inserted > 0);
104
104
  });
105
+ it("merge raw log from different day: time window filters out all raw events", () => {
106
+ const sessionId = "sess_merge_target_time_window";
107
+ const sessionStartTs = "2026-03-02T20:55:12.151Z";
108
+ const sessionEndTs = "2026-03-02T20:57:42.004Z";
109
+ const sessionLines = [
110
+ JSON.stringify({
111
+ id: `${sessionId}:1:aa`,
112
+ session_id: sessionId,
113
+ seq: 1,
114
+ ts: sessionStartTs,
115
+ kind: "session_start",
116
+ actor: { type: "agent" },
117
+ payload: { goal: "Test" },
118
+ schema_version: 1,
119
+ }),
120
+ JSON.stringify({
121
+ id: `${sessionId}:2:bb`,
122
+ session_id: sessionId,
123
+ seq: 2,
124
+ ts: sessionEndTs,
125
+ kind: "session_end",
126
+ actor: { type: "agent" },
127
+ payload: { outcome: "completed" },
128
+ schema_version: 1,
129
+ }),
130
+ ].join("\n") + "\n";
131
+ writeFileSync(join(sessionsDir, `${sessionId}.jsonl`), sessionLines, "utf-8");
132
+ const raw = readFileSync(fixturePath("codex_sample.jsonl"), "utf-8");
133
+ const result = ingestRawContent(raw, {
134
+ adapter: "codex_jsonl",
135
+ merge_session_id: sessionId,
136
+ });
137
+ assert.strictEqual(result.session_id, sessionId);
138
+ assert.strictEqual(result.merge_strategy, "explicit_merge");
139
+ assert.strictEqual(result.inserted, 0, "no raw events fall in Mar 2 window");
140
+ assert.ok(result.filtered_out_by_time_window !== undefined && result.filtered_out_by_time_window > 0, "raw events (Feb 24) were filtered out by time window");
141
+ const eventsAfter = readSessionEvents(sessionId);
142
+ assert.strictEqual(eventsAfter.length, 2, "session still has only session_start and session_end");
143
+ });
105
144
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { deriveIntentTokenBreakdown, generateFollowupArtifacts } from "../local-analysis.js";
4
+ function baseEvent(overrides) {
5
+ return {
6
+ id: "e-1",
7
+ session_id: "sess-test",
8
+ seq: 1,
9
+ ts: "2026-03-03T00:00:00.000Z",
10
+ kind: "intent",
11
+ actor: { type: "agent" },
12
+ payload: {},
13
+ schema_version: 1,
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("local-analysis", () => {
18
+ it("generates per-intent artifacts with deterministic template", () => {
19
+ const events = [
20
+ baseEvent({
21
+ id: "i1",
22
+ seq: 1,
23
+ kind: "intent",
24
+ scope: { intent_id: "intent_a" },
25
+ payload: { intent_id: "intent_a", title: "Implement feature A" },
26
+ }),
27
+ baseEvent({
28
+ id: "t1",
29
+ seq: 2,
30
+ kind: "tool_call",
31
+ scope: { intent_id: "intent_a" },
32
+ payload: { category: "tool", action: "read_file", target: "src/a.ts" },
33
+ }),
34
+ baseEvent({
35
+ id: "t2",
36
+ seq: 3,
37
+ kind: "tool_call",
38
+ scope: { intent_id: "intent_a" },
39
+ payload: { category: "tool", action: "read_file", target: "src/a.ts" },
40
+ }),
41
+ baseEvent({
42
+ id: "t3",
43
+ seq: 4,
44
+ kind: "tool_call",
45
+ scope: { intent_id: "intent_a" },
46
+ payload: { category: "tool", action: "read_file", target: "src/a.ts" },
47
+ }),
48
+ baseEvent({
49
+ id: "v1",
50
+ seq: 5,
51
+ kind: "verification",
52
+ scope: { intent_id: "intent_a" },
53
+ payload: { type: "test", result: "fail" },
54
+ }),
55
+ baseEvent({
56
+ id: "r1",
57
+ seq: 6,
58
+ kind: "risk_signal",
59
+ scope: { intent_id: "intent_a", file: "src/a.ts" },
60
+ payload: { level: "high", reasons: ["regression risk"] },
61
+ }),
62
+ ];
63
+ const result = generateFollowupArtifacts(events, {
64
+ mode: "per_intent",
65
+ strictness: "soft",
66
+ focus: "risk",
67
+ });
68
+ assert.equal(result.artifacts.length, 1);
69
+ assert.equal(result.artifacts[0].intent_id, "intent_a");
70
+ assert.equal(result.artifacts[0].rule_template_id, "high_risk_guardrail");
71
+ assert.ok(result.artifacts[0].value_claims.risk_mitigation.length > 0);
72
+ });
73
+ it("derives token breakdown context/output split", () => {
74
+ const events = [
75
+ baseEvent({
76
+ id: "i1",
77
+ seq: 1,
78
+ kind: "intent",
79
+ scope: { intent_id: "intent_a" },
80
+ payload: { intent_id: "intent_a", title: "Intent A" },
81
+ }),
82
+ baseEvent({
83
+ id: "ctx1",
84
+ seq: 2,
85
+ kind: "tool_call",
86
+ scope: { intent_id: "intent_a" },
87
+ payload: { category: "search", action: "search_docs", target: "api docs" },
88
+ }),
89
+ baseEvent({
90
+ id: "tok1",
91
+ seq: 3,
92
+ kind: "token_usage_checkpoint",
93
+ scope: { intent_id: "intent_a" },
94
+ payload: { usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } },
95
+ }),
96
+ baseEvent({
97
+ id: "out1",
98
+ seq: 4,
99
+ kind: "file_op",
100
+ scope: { intent_id: "intent_a", file: "src/a.ts" },
101
+ payload: { category: "file", action: "edit", target: "src/a.ts" },
102
+ }),
103
+ baseEvent({
104
+ id: "tok2",
105
+ seq: 5,
106
+ kind: "token_usage_checkpoint",
107
+ scope: { intent_id: "intent_a" },
108
+ payload: { usage: { total_tokens: 90 } },
109
+ }),
110
+ ];
111
+ const result = deriveIntentTokenBreakdown(events);
112
+ assert.equal(result.intent_breakdown.length, 1);
113
+ assert.equal(result.totals.total_tokens, 240);
114
+ assert.equal(result.intent_breakdown[0].context_tokens +
115
+ result.intent_breakdown[0].output_tokens +
116
+ result.intent_breakdown[0].unknown_tokens, 240);
117
+ });
118
+ });
@@ -4,11 +4,24 @@ function toObject(value) {
4
4
  : {};
5
5
  }
6
6
  function toIso(ts, fallback) {
7
+ if (ts == null)
8
+ return fallback;
9
+ if (typeof ts === "number" && Number.isFinite(ts)) {
10
+ const parsed = new Date(ts);
11
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed.toISOString();
12
+ }
7
13
  if (typeof ts !== "string" || ts.trim() === "")
8
14
  return fallback;
9
15
  const parsed = new Date(ts);
10
16
  return Number.isNaN(parsed.getTime()) ? fallback : parsed.toISOString();
11
17
  }
18
+ /** Prefer top-level timestamp, then payload.timestamp / payload.created_at. */
19
+ function getRecordTimestamp(record) {
20
+ if (record.timestamp !== undefined && record.timestamp !== null)
21
+ return record.timestamp;
22
+ const payload = toObject(record.payload);
23
+ return payload.timestamp ?? payload.created_at;
24
+ }
12
25
  function short(value, max = 800) {
13
26
  if (typeof value !== "string")
14
27
  return undefined;
@@ -67,7 +80,7 @@ function parseLines(content) {
67
80
  function mapResponseItem(record, intentId, now) {
68
81
  const payload = toObject(record.payload);
69
82
  const itemType = short(payload.type) ?? "unknown";
70
- const ts = toIso(record.timestamp, now);
83
+ const ts = toIso(getRecordTimestamp(record), now);
71
84
  if (itemType === "function_call" || itemType === "custom_tool_call" || itemType === "web_search_call") {
72
85
  const action = short(payload.name) ?? short(toObject(payload.action).type) ?? itemType;
73
86
  return [
@@ -189,7 +202,7 @@ export const codexJsonlAdapter = {
189
202
  }
190
203
  const meta = toObject(sessionMeta.payload);
191
204
  const sessionId = short(meta.id) ?? `codex_${Date.now()}`;
192
- const start = toIso(meta.timestamp, toIso(sessionMeta.timestamp, now));
205
+ const start = toIso(getRecordTimestamp(sessionMeta) ?? meta.timestamp, now);
193
206
  let intentCounter = 0;
194
207
  let activeIntentId;
195
208
  const events = [];
@@ -216,7 +229,7 @@ export const codexJsonlAdapter = {
216
229
  activeIntentId = `intent_${sessionId}_${intentCounter}`;
217
230
  events.push({
218
231
  kind: "intent",
219
- ts: toIso(record.timestamp, now),
232
+ ts: toIso(getRecordTimestamp(record), now),
220
233
  actor: { type: "user", id: "codex-user" },
221
234
  scope: { intent_id: activeIntentId },
222
235
  payload: {
@@ -235,7 +248,7 @@ export const codexJsonlAdapter = {
235
248
  const usage = normalizeTokenUsage(p.info);
236
249
  events.push({
237
250
  kind: "token_usage_checkpoint",
238
- ts: toIso(record.timestamp, now),
251
+ ts: toIso(getRecordTimestamp(record), now),
239
252
  actor: { type: "system", id: "codex" },
240
253
  scope: activeIntentId ? { intent_id: activeIntentId, module: "llm" } : { module: "llm" },
241
254
  payload: {
@@ -253,7 +266,7 @@ export const codexJsonlAdapter = {
253
266
  if (reasoning) {
254
267
  events.push({
255
268
  kind: "artifact_created",
256
- ts: toIso(record.timestamp, now),
269
+ ts: toIso(getRecordTimestamp(record), now),
257
270
  actor: { type: "agent", id: "codex" },
258
271
  scope: activeIntentId
259
272
  ? { intent_id: activeIntentId, module: "reasoning" }
@@ -274,7 +287,7 @@ export const codexJsonlAdapter = {
274
287
  if (assistantMessage) {
275
288
  events.push({
276
289
  kind: "artifact_created",
277
- ts: toIso(record.timestamp, now),
290
+ ts: toIso(getRecordTimestamp(record), now),
278
291
  actor: { type: "agent", id: "codex" },
279
292
  scope: activeIntentId
280
293
  ? { intent_id: activeIntentId, module: "assistant_output" }
@@ -295,7 +308,16 @@ export const codexJsonlAdapter = {
295
308
  events.push(...mapResponseItem(record, activeIntentId, now));
296
309
  }
297
310
  }
298
- const endTs = toIso(records[records.length - 1]?.timestamp, now);
311
+ const lastRecordTs = records.reduce((acc, r) => {
312
+ const t = getRecordTimestamp(r);
313
+ const ms = typeof t === "number" && Number.isFinite(t) ? t : (typeof t === "string" ? new Date(t).getTime() : NaN);
314
+ if (Number.isNaN(ms))
315
+ return acc;
316
+ return acc == null ? ms : Math.max(acc, ms);
317
+ }, null);
318
+ const endTs = lastRecordTs != null
319
+ ? new Date(lastRecordTs).toISOString()
320
+ : toIso(records[records.length - 1]?.timestamp, now);
299
321
  events.push({
300
322
  kind: "session_end",
301
323
  ts: endTs,
package/dist/dashboard.js CHANGED
@@ -2,10 +2,12 @@ import { createServer } from "node:http";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { extname, join, normalize, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { EVENT_SCHEMA_VERSION } from "./event-envelope.js";
5
6
  import { getDashboardHost, getDashboardPort, getDashboardWebappDir, getSessionsDir, isDashboardEnabled, } from "./config.js";
6
7
  import { exportSessionJson } from "./store.js";
7
8
  import { handleGatewayAct, handleGatewayBeginRun, handleGatewayEndRun } from "./tools.js";
8
9
  import { ingestRawContent } from "./ingest.js";
10
+ import { deriveIntentTokenBreakdown, generateFollowupArtifacts, } from "./local-analysis.js";
9
11
  const importSets = new Map();
10
12
  function json(res, status, payload) {
11
13
  res.writeHead(status, {
@@ -170,6 +172,66 @@ function summarizeSessionPayload(payload) {
170
172
  event_count: payload.events.length,
171
173
  };
172
174
  }
175
+ /** Merge multiple session payloads into one: combined events sorted by ts, single session_id, re-sequenced. */
176
+ function mergeSessionPayloads(payloads) {
177
+ if (payloads.length === 0)
178
+ throw new Error("No payloads to merge.");
179
+ if (payloads.length === 1)
180
+ return payloads[0];
181
+ const mergedSessionId = `merged_${Date.now()}_${randomUUID().slice(0, 8)}`;
182
+ const expectedTotal = payloads.reduce((sum, p) => sum + p.events.length, 0);
183
+ const allEvents = [];
184
+ for (const payload of payloads) {
185
+ for (const e of payload.events) {
186
+ allEvents.push({
187
+ id: e.id,
188
+ session_id: mergedSessionId,
189
+ seq: e.seq,
190
+ ts: e.ts,
191
+ kind: e.kind,
192
+ actor: { ...e.actor },
193
+ scope: e.scope ? { ...e.scope } : undefined,
194
+ payload: typeof e.payload === "object" && e.payload !== null ? { ...e.payload } : e.payload,
195
+ derived: e.derived,
196
+ confidence: e.confidence,
197
+ visibility: e.visibility,
198
+ schema_version: e.schema_version ?? EVENT_SCHEMA_VERSION,
199
+ });
200
+ }
201
+ }
202
+ if (allEvents.length !== expectedTotal) {
203
+ throw new Error(`Merge event count mismatch: collected ${allEvents.length}, expected ${expectedTotal} from ${payloads.length} payload(s).`);
204
+ }
205
+ allEvents.sort((a, b) => {
206
+ const tsCmp = a.ts.localeCompare(b.ts);
207
+ if (tsCmp !== 0)
208
+ return tsCmp;
209
+ return (a.seq ?? 0) - (b.seq ?? 0);
210
+ });
211
+ let seq = 0;
212
+ const mergedEvents = allEvents.map((e) => {
213
+ seq += 1;
214
+ return {
215
+ ...e,
216
+ id: `${mergedSessionId}:${seq}:${randomUUID().slice(0, 8)}`,
217
+ seq,
218
+ schema_version: e.schema_version ?? EVENT_SCHEMA_VERSION,
219
+ };
220
+ });
221
+ const first = payloads[0];
222
+ const last = payloads[payloads.length - 1];
223
+ const firstStart = mergedEvents.find((e) => e.kind === "session_start");
224
+ const lastEnd = [...mergedEvents].reverse().find((e) => e.kind === "session_end");
225
+ const startPayload = (firstStart?.payload ?? {});
226
+ return {
227
+ session_id: mergedSessionId,
228
+ goal: typeof startPayload.goal === "string" ? startPayload.goal : first.goal ?? "Merged session",
229
+ user_prompt: first.user_prompt,
230
+ started_at: firstStart?.ts ?? first.started_at ?? mergedEvents[0]?.ts,
231
+ ended_at: lastEnd?.ts ?? last.ended_at,
232
+ events: mergedEvents,
233
+ };
234
+ }
173
235
  function buildFollowupFromEvents(events, focus) {
174
236
  const risks = events.filter((event) => event.kind === "risk_signal");
175
237
  const fails = events.filter((event) => event.kind === "verification" && event.payload?.result === "fail");
@@ -475,7 +537,7 @@ async function handleApi(req, res, pathname) {
475
537
  json(res, 400, { error: "Missing `files` array. Provide one or more MCP canonical session logs." });
476
538
  return true;
477
539
  }
478
- const accepted = [];
540
+ const acceptedPayloads = [];
479
541
  const rejected_files = [];
480
542
  for (const item of filesRaw) {
481
543
  const file = item;
@@ -486,13 +548,7 @@ async function handleApi(req, res, pathname) {
486
548
  }
487
549
  try {
488
550
  const payload = parseSessionContent(file.content);
489
- writeCanonicalSession(payload);
490
- const summary = summarizeSessionPayload(payload);
491
- accepted.push({
492
- ...summary,
493
- size_bytes: Buffer.byteLength(file.content, "utf-8"),
494
- updated_at: new Date().toISOString(),
495
- });
551
+ acceptedPayloads.push(payload);
496
552
  }
497
553
  catch (error) {
498
554
  rejected_files.push({
@@ -503,24 +559,43 @@ async function handleApi(req, res, pathname) {
503
559
  });
504
560
  }
505
561
  }
506
- if (accepted.length === 0) {
562
+ if (acceptedPayloads.length === 0) {
507
563
  json(res, 400, {
508
564
  error: "No canonical MCP session logs were accepted.",
509
565
  rejected_files,
510
566
  });
511
567
  return true;
512
568
  }
569
+ const mergedPayload = acceptedPayloads.length === 1
570
+ ? acceptedPayloads[0]
571
+ : mergeSessionPayloads(acceptedPayloads);
572
+ writeCanonicalSession(mergedPayload);
573
+ const summary = summarizeSessionPayload(mergedPayload);
574
+ const accepted = [
575
+ {
576
+ ...summary,
577
+ size_bytes: mergedPayload.events.reduce((acc, e) => acc + Buffer.byteLength(JSON.stringify(e), "utf-8"), 0),
578
+ updated_at: new Date().toISOString(),
579
+ },
580
+ ];
513
581
  const import_set_id = `iset_${Date.now()}_${randomUUID().slice(0, 8)}`;
514
582
  importSets.set(import_set_id, {
515
583
  import_set_id,
516
584
  created_at: new Date().toISOString(),
517
- session_ids: [...new Set(accepted.map((item) => item.session_id))],
585
+ session_ids: [mergedPayload.session_id],
518
586
  });
587
+ const multipleRejected = filesRaw.length > 1 && rejected_files.length > 0;
519
588
  json(res, 200, {
520
589
  import_set_id,
521
590
  sessions: accepted,
522
591
  rejected_files,
523
- guidance: "Import only relevant or consecutive sessions from the same thread/conversation to avoid noisy insights.",
592
+ accepted_file_count: acceptedPayloads.length,
593
+ total_event_count: mergedPayload.events.length,
594
+ guidance: multipleRejected
595
+ ? `${acceptedPayloads.length} of ${filesRaw.length} files were valid. Fix or remove rejected files to merge all session logs.`
596
+ : acceptedPayloads.length > 1
597
+ ? "Multiple session logs were merged into one. Raw logs will merge into this combined session."
598
+ : "Import only relevant or consecutive sessions from the same thread/conversation to avoid noisy insights.",
524
599
  });
525
600
  }
526
601
  catch (error) {
@@ -584,7 +659,15 @@ async function handleApi(req, res, pathname) {
584
659
  try {
585
660
  const body = await readJsonBody(req);
586
661
  const scope = typeof body.scope === "string" ? body.scope : "session";
587
- const focus = typeof body.focus === "string" ? body.focus : "risk";
662
+ const focus = body.focus === "risk" ||
663
+ body.focus === "verification_gap" ||
664
+ body.focus === "hotspot" ||
665
+ body.focus === "rework_pattern" ||
666
+ body.focus === "efficiency"
667
+ ? body.focus
668
+ : "risk";
669
+ const mode = body.mode === "session" ? "session" : "per_intent";
670
+ const strictness = body.strictness === "advisory" || body.strictness === "hard" ? body.strictness : "soft";
588
671
  const importSetId = typeof body.import_set_id === "string" ? body.import_set_id : "";
589
672
  const sessionId = typeof body.session_id === "string" ? body.session_id : "";
590
673
  const summaries = listSessionFiles();
@@ -609,10 +692,16 @@ async function handleApi(req, res, pathname) {
609
692
  json(res, 404, { error: "No events found for requested follow-up scope." });
610
693
  return true;
611
694
  }
612
- const generated = buildFollowupFromEvents(collectedEvents, focus);
695
+ const generated = generateFollowupArtifacts(collectedEvents, {
696
+ focus,
697
+ mode,
698
+ strictness,
699
+ });
613
700
  json(res, 200, {
614
701
  scope,
615
702
  focus,
703
+ mode,
704
+ strictness,
616
705
  session_ids: scopedSessionIds,
617
706
  generated_at: new Date().toISOString(),
618
707
  ...generated,
@@ -656,6 +745,29 @@ async function handleApi(req, res, pathname) {
656
745
  }
657
746
  return true;
658
747
  }
748
+ if (key.endsWith("/token-breakdown")) {
749
+ const rawKey = key.slice(0, -"/token-breakdown".length);
750
+ const summary = listSessionFiles().find((item) => item.key === rawKey || item.session_id === rawKey);
751
+ if (!summary) {
752
+ json(res, 404, { error: "Session not found." });
753
+ return true;
754
+ }
755
+ try {
756
+ const payload = readSessionFile(summary.absolute_path);
757
+ const breakdown = deriveIntentTokenBreakdown(payload.events);
758
+ json(res, 200, {
759
+ session_id: summary.session_id,
760
+ generated_at: new Date().toISOString(),
761
+ ...breakdown,
762
+ });
763
+ }
764
+ catch (error) {
765
+ json(res, 500, {
766
+ error: error instanceof Error ? error.message : "Failed to derive token breakdown.",
767
+ });
768
+ }
769
+ return true;
770
+ }
659
771
  const summary = listSessionFiles().find((item) => item.key === key || item.session_id === key);
660
772
  if (!summary) {
661
773
  json(res, 404, { error: "Session not found." });
package/dist/index.js CHANGED
File without changes
package/dist/ingest.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface IngestResult {
8
8
  adapter: string;
9
9
  inserted: number;
10
10
  skipped_duplicates: number;
11
+ /** When merging into an existing session, number of raw events dropped by the time-window filter. */
12
+ filtered_out_by_time_window?: number;
11
13
  session_path: string;
12
14
  raw_path: string;
13
15
  merge_strategy: "explicit_merge" | "adapted_session_id" | "fingerprint_match" | "new_session";
package/dist/ingest.js CHANGED
@@ -89,6 +89,59 @@ function toMs(raw) {
89
89
  const ms = new Date(raw).getTime();
90
90
  return Number.isNaN(ms) ? undefined : ms;
91
91
  }
92
+ /** Padding (ms) around session time range when merging. Use 0 so only events strictly inside the session window are kept; raw adapters often synthesize timestamps at "now" which would otherwise fall in session_end+padding. */
93
+ const MERGE_TIME_PADDING_MS = 0;
94
+ /**
95
+ * Session time range from existing canonical events.
96
+ * Uses session_start / session_end ts when present, else first/last event ts.
97
+ */
98
+ function getSessionTimeRange(existing) {
99
+ if (existing.length === 0) {
100
+ const now = Date.now();
101
+ return { startMs: now, endMs: now };
102
+ }
103
+ let sessionStartMs;
104
+ let sessionEndMs;
105
+ for (const e of existing) {
106
+ const ms = toMs(e.ts);
107
+ if (ms == null)
108
+ continue;
109
+ if (e.kind === "session_start")
110
+ sessionStartMs = ms;
111
+ if (e.kind === "session_end")
112
+ sessionEndMs = ms;
113
+ }
114
+ const firstMs = toMs(existing[0]?.ts);
115
+ const lastMs = toMs(existing[existing.length - 1]?.ts);
116
+ return {
117
+ startMs: sessionStartMs ?? firstMs ?? Date.now(),
118
+ endMs: sessionEndMs ?? lastMs ?? Date.now(),
119
+ };
120
+ }
121
+ /**
122
+ * When merging raw into an existing session, keep only events that fall inside the
123
+ * session time window (with padding) and drop session_start/session_end from raw.
124
+ * Events without a parseable ts are excluded. Events on a different calendar day (UTC)
125
+ * from the session range are always excluded to avoid timezone/clock skew.
126
+ */
127
+ function filterAdaptedEventsForMerge(events, range, paddingMs, fallbackTs) {
128
+ const minMs = range.startMs - paddingMs;
129
+ const maxMs = range.endMs + paddingMs;
130
+ const startDay = new Date(range.startMs).toISOString().slice(0, 10);
131
+ const endDay = new Date(range.endMs).toISOString().slice(0, 10);
132
+ return events.filter((event) => {
133
+ if (event.kind === "session_start" || event.kind === "session_end")
134
+ return false;
135
+ const ts = toIso(event.ts, fallbackTs);
136
+ const ms = toMs(ts);
137
+ if (ms == null)
138
+ return false;
139
+ const eventDay = new Date(ms).toISOString().slice(0, 10);
140
+ if (eventDay < startDay || eventDay > endDay)
141
+ return false;
142
+ return ms >= minMs && ms <= maxMs;
143
+ });
144
+ }
92
145
  function normalizeFingerprint(value) {
93
146
  if (!value)
94
147
  return "";
@@ -362,13 +415,21 @@ export function ingestRawContent(raw, options = {}) {
362
415
  const doDedupe = options.dedupe ?? true;
363
416
  const fallbackTs = new Date().toISOString();
364
417
  const isMerge = existing.length > 0;
418
+ const isExplicitMerge = isMerge && Boolean(options.merge_session_id);
365
419
  const existingSemanticKeys = new Set(existing.map((e) => semanticDedupeKey(e)));
366
420
  const existingExactKeys = new Set(existing.map((e) => dedupeKey(e)));
421
+ const range = getSessionTimeRange(existing);
422
+ const eventsToProcess = isExplicitMerge
423
+ ? filterAdaptedEventsForMerge(adapted.events, range, MERGE_TIME_PADDING_MS, fallbackTs)
424
+ : adapted.events;
425
+ const filteredOutByTimeWindow = isExplicitMerge && adapted.events.length > eventsToProcess.length
426
+ ? adapted.events.length - eventsToProcess.length
427
+ : undefined;
367
428
  let seq = existing.length > 0 ? Math.max(...existing.map((e) => e.seq)) : 0;
368
429
  let inserted = 0;
369
430
  let skipped = 0;
370
431
  const toInsert = [];
371
- for (const event of adapted.events) {
432
+ for (const event of eventsToProcess) {
372
433
  const candidate = buildCanonicalEvent(sessionId, seq + 1, event, fallbackTs);
373
434
  if (doDedupe) {
374
435
  if (isMerge && existingSemanticKeys.has(semanticDedupeKey(candidate))) {
@@ -402,7 +463,7 @@ export function ingestRawContent(raw, options = {}) {
402
463
  sessionPath = writeEventsAppend(sessionId, toInsert);
403
464
  }
404
465
  const rawPath = writeRawSidecar(sessionId, adapted.source, raw);
405
- return {
466
+ const result = {
406
467
  session_id: sessionId,
407
468
  adapter: adapted.source,
408
469
  inserted,
@@ -412,6 +473,10 @@ export function ingestRawContent(raw, options = {}) {
412
473
  merge_strategy: sessionSelection.strategy,
413
474
  merge_confidence: sessionSelection.fingerprint_confidence,
414
475
  };
476
+ if (filteredOutByTimeWindow !== undefined) {
477
+ result.filtered_out_by_time_window = filteredOutByTimeWindow;
478
+ }
479
+ return result;
415
480
  }
416
481
  export function ingestRawFile(filePath, options = {}) {
417
482
  const raw = readFileSync(filePath, "utf-8");
@@ -0,0 +1,91 @@
1
+ import type { CanonicalEvent } from "./event-envelope.js";
2
+ export type FollowupMode = "per_intent" | "session";
3
+ export type FollowupStrictness = "soft" | "advisory" | "hard";
4
+ export type FollowupFocus = "risk" | "verification_gap" | "hotspot" | "rework_pattern" | "efficiency";
5
+ export type RuleTemplateId = "high_risk_guardrail" | "verification_loop_control" | "context_retrieval_limit" | "file_check_dedup" | "hotspot_change_gate";
6
+ export interface ValueClaims {
7
+ risk_mitigation: string[];
8
+ efficiency_improvement: string[];
9
+ quality_standardization: string[];
10
+ }
11
+ export interface EvidenceRef {
12
+ event_id: string;
13
+ reason: string;
14
+ file?: string;
15
+ }
16
+ export interface IntentRuleFeatures {
17
+ intent_id: string;
18
+ intent_title: string;
19
+ risk_score: number;
20
+ efficiency_waste_score: number;
21
+ stability_score: number;
22
+ high_risk_signals: number;
23
+ verification_fail_count: number;
24
+ verification_unknown_count: number;
25
+ hotspot_score: number;
26
+ repeated_call_count: number;
27
+ repeated_file_check_count: number;
28
+ file_ops_count: number;
29
+ token_total: number;
30
+ }
31
+ export interface FollowupArtifact {
32
+ intent_id: string;
33
+ intent_title: string;
34
+ confidence: number;
35
+ rule_template_id: RuleTemplateId;
36
+ features: IntentRuleFeatures;
37
+ value_claims: ValueClaims;
38
+ evidence_refs: EvidenceRef[];
39
+ rule_spec: Record<string, unknown>;
40
+ skill_draft: string;
41
+ insufficient_evidence?: boolean;
42
+ }
43
+ export interface FollowupSummary {
44
+ intent_count: number;
45
+ high_risk_intents: number;
46
+ high_efficiency_waste_intents: number;
47
+ avg_confidence: number;
48
+ }
49
+ export interface FollowupGenerationResult {
50
+ artifacts: FollowupArtifact[];
51
+ summary: FollowupSummary;
52
+ insufficient_evidence: boolean;
53
+ }
54
+ export interface TokenSplitAttribution {
55
+ checkpoint_event_id: string;
56
+ total_tokens: number;
57
+ context_tokens: number;
58
+ output_tokens: number;
59
+ unknown_tokens: number;
60
+ neighbor_event_ids: string[];
61
+ }
62
+ export interface IntentTokenBreakdown {
63
+ intent_id: string;
64
+ intent_title: string;
65
+ total_tokens: number;
66
+ context_tokens: number;
67
+ output_tokens: number;
68
+ unknown_tokens: number;
69
+ estimated_cost_usd?: number;
70
+ event_window: {
71
+ start_seq: number;
72
+ end_seq: number;
73
+ };
74
+ supporting_events: TokenSplitAttribution[];
75
+ }
76
+ export interface TokenBreakdownResult {
77
+ intent_breakdown: IntentTokenBreakdown[];
78
+ totals: {
79
+ total_tokens: number;
80
+ context_tokens: number;
81
+ output_tokens: number;
82
+ unknown_tokens: number;
83
+ estimated_cost_usd?: number;
84
+ };
85
+ }
86
+ export declare function generateFollowupArtifacts(events: CanonicalEvent[], input?: {
87
+ mode?: FollowupMode;
88
+ strictness?: FollowupStrictness;
89
+ focus?: FollowupFocus;
90
+ }): FollowupGenerationResult;
91
+ export declare function deriveIntentTokenBreakdown(events: CanonicalEvent[]): TokenBreakdownResult;
@@ -0,0 +1,517 @@
1
+ const MAX_REPEATED_CHECKS_SOFT = 3;
2
+ const MAX_REPEATED_CALLS_SOFT = 4;
3
+ const TOKEN_NEIGHBORHOOD_RADIUS_SEQ = 5;
4
+ const MIN_RULE_CONFIDENCE = 0.45;
5
+ function toNumber(value) {
6
+ if (typeof value === "number")
7
+ return Number.isFinite(value) ? value : 0;
8
+ if (typeof value === "string" && value.trim() !== "") {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) ? parsed : 0;
11
+ }
12
+ return 0;
13
+ }
14
+ function toStringValue(value) {
15
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
16
+ }
17
+ function payload(event) {
18
+ return event.payload ?? {};
19
+ }
20
+ function getIntentId(event) {
21
+ const p = payload(event);
22
+ const payloadIntent = toStringValue(p.intent_id);
23
+ return event.scope?.intent_id ?? payloadIntent;
24
+ }
25
+ function getIntentTitle(event) {
26
+ const p = payload(event);
27
+ return toStringValue(p.title) ?? toStringValue(p.description);
28
+ }
29
+ function getFileTarget(event) {
30
+ if (event.kind !== "file_op")
31
+ return undefined;
32
+ const p = payload(event);
33
+ return toStringValue(p.target) ?? event.scope?.file;
34
+ }
35
+ function getTokenUsage(event) {
36
+ const p = payload(event);
37
+ const direct = p.usage;
38
+ const details = p.details;
39
+ const nested = details && typeof details === "object" ? details.llm_usage : undefined;
40
+ const usage = direct && typeof direct === "object"
41
+ ? direct
42
+ : nested && typeof nested === "object"
43
+ ? nested
44
+ : null;
45
+ if (!usage)
46
+ return null;
47
+ const prompt = toNumber(usage.prompt_tokens);
48
+ const completion = toNumber(usage.completion_tokens);
49
+ const totalRaw = toNumber(usage.total_tokens);
50
+ const total = totalRaw > 0 ? totalRaw : prompt + completion;
51
+ if (total <= 0)
52
+ return null;
53
+ const cost = toNumber(usage.estimated_cost_usd);
54
+ return {
55
+ total_tokens: total,
56
+ estimated_cost_usd: cost > 0 ? cost : undefined,
57
+ };
58
+ }
59
+ function percentile(values, p) {
60
+ if (values.length === 0)
61
+ return 0;
62
+ const sorted = [...values].sort((a, b) => a - b);
63
+ const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p)));
64
+ return sorted[idx];
65
+ }
66
+ function buildIntentBuckets(eventsRaw) {
67
+ const events = [...eventsRaw].sort((a, b) => (a.seq === b.seq ? a.ts.localeCompare(b.ts) : a.seq - b.seq));
68
+ const boundaryEvents = events.filter((event) => event.kind === "intent");
69
+ const boundaries = boundaryEvents.map((event) => ({
70
+ intent_id: getIntentId(event) ?? `intent_${event.seq}`,
71
+ intent_title: getIntentTitle(event) ?? `Intent ${event.seq}`,
72
+ seq: event.seq,
73
+ }));
74
+ const boundaryById = new Map(boundaries.map((item) => [item.intent_id, item.intent_title]));
75
+ const fallbackId = "intent_fallback";
76
+ const byIntent = new Map();
77
+ function inferIntent(event) {
78
+ const explicit = getIntentId(event);
79
+ if (explicit)
80
+ return explicit;
81
+ let latest;
82
+ for (const boundary of boundaries) {
83
+ if (boundary.seq <= event.seq)
84
+ latest = boundary.intent_id;
85
+ else
86
+ break;
87
+ }
88
+ return latest ?? fallbackId;
89
+ }
90
+ for (const event of events) {
91
+ if (event.kind === "session_start" || event.kind === "session_end")
92
+ continue;
93
+ const intentId = inferIntent(event);
94
+ const list = byIntent.get(intentId) ?? [];
95
+ list.push(event);
96
+ byIntent.set(intentId, list);
97
+ }
98
+ const orderedIds = [...new Set([...boundaries.map((item) => item.intent_id), ...byIntent.keys()])];
99
+ return orderedIds.map((intentId) => {
100
+ const intentEvents = byIntent.get(intentId) ?? [];
101
+ const startSeq = intentEvents[0]?.seq ?? 0;
102
+ const endSeq = intentEvents[intentEvents.length - 1]?.seq ?? startSeq;
103
+ return {
104
+ intent_id: intentId,
105
+ intent_title: boundaryById.get(intentId) ?? (intentId === fallbackId ? "Fallback intent" : intentId),
106
+ start_seq: startSeq,
107
+ end_seq: endSeq,
108
+ events: intentEvents,
109
+ };
110
+ });
111
+ }
112
+ function isFileCheckEvent(event) {
113
+ if (event.kind !== "tool_call")
114
+ return false;
115
+ const p = payload(event);
116
+ const action = (toStringValue(p.action) ?? "").toLowerCase();
117
+ const target = (toStringValue(p.target) ?? "").toLowerCase();
118
+ if (!action && !target)
119
+ return false;
120
+ const looksCheck = /(read|cat|open|view|inspect|grep|rg|ls|stat)/.test(action);
121
+ const looksFile = /\/|\\|\.ts$|\.tsx$|\.js$|\.json$|\.md$|\.yml$|\.yaml$/.test(target);
122
+ return looksCheck || looksFile;
123
+ }
124
+ function isContextEvent(event) {
125
+ if (event.kind === "tool_call") {
126
+ const category = toStringValue(payload(event).category);
127
+ return category === "search" || category === "tool" || category === "execution";
128
+ }
129
+ if (event.kind === "artifact_created") {
130
+ const module = (event.scope?.module ?? "").toLowerCase();
131
+ const artifactType = (toStringValue(payload(event).artifact_type) ?? "").toLowerCase();
132
+ return module === "reasoning" || artifactType === "reasoning";
133
+ }
134
+ return false;
135
+ }
136
+ function isOutputEvent(event) {
137
+ if (event.kind === "file_op" || event.kind === "diff_summary")
138
+ return true;
139
+ if (event.kind === "artifact_created") {
140
+ const artifactType = (toStringValue(payload(event).artifact_type) ?? "").toLowerCase();
141
+ return ["file", "patch", "report", "test", "build", "migration", "pr"].includes(artifactType);
142
+ }
143
+ return false;
144
+ }
145
+ function deriveIntentFeatures(bucket, repeatedCallThreshold, repeatedCheckThreshold) {
146
+ const evidence = [];
147
+ let highRiskSignals = 0;
148
+ let verificationFailCount = 0;
149
+ let verificationUnknownCount = 0;
150
+ let hotspotScore = 0;
151
+ let fileOpsCount = 0;
152
+ let tokenTotal = 0;
153
+ let tokenCost = 0;
154
+ const callKeyCount = new Map();
155
+ const fileCheckCount = new Map();
156
+ const editedFiles = new Set();
157
+ let diffSummaryCount = 0;
158
+ let outputArtifacts = 0;
159
+ for (const event of bucket.events) {
160
+ const p = payload(event);
161
+ if (event.kind === "risk_signal") {
162
+ const level = toStringValue(p.level) ?? "low";
163
+ if (level === "high")
164
+ highRiskSignals += 3;
165
+ else if (level === "medium")
166
+ highRiskSignals += 2;
167
+ else
168
+ highRiskSignals += 1;
169
+ evidence.push({ event_id: event.id, reason: `risk_signal:${level}`, file: event.scope?.file });
170
+ }
171
+ if (event.kind === "assumption") {
172
+ const risk = toStringValue(p.risk);
173
+ if (risk === "high")
174
+ highRiskSignals += 2;
175
+ else if (risk === "medium")
176
+ highRiskSignals += 1;
177
+ }
178
+ if (event.kind === "verification") {
179
+ const result = toStringValue(p.result);
180
+ if (result === "fail") {
181
+ verificationFailCount += 1;
182
+ evidence.push({ event_id: event.id, reason: "verification:fail", file: event.scope?.file });
183
+ }
184
+ else if (result === "unknown") {
185
+ verificationUnknownCount += 1;
186
+ }
187
+ }
188
+ if (event.kind === "hotspot") {
189
+ const score = toNumber(p.score);
190
+ hotspotScore += score;
191
+ evidence.push({ event_id: event.id, reason: `hotspot:${score}`, file: toStringValue(p.file) ?? event.scope?.file });
192
+ }
193
+ if (event.kind === "tool_call") {
194
+ const key = `${toStringValue(p.action) ?? "unknown"}::${toStringValue(p.target) ?? ""}`;
195
+ callKeyCount.set(key, (callKeyCount.get(key) ?? 0) + 1);
196
+ if (isFileCheckEvent(event)) {
197
+ const target = toStringValue(p.target) ?? toStringValue(event.scope?.file) ?? "unknown";
198
+ fileCheckCount.set(target, (fileCheckCount.get(target) ?? 0) + 1);
199
+ }
200
+ }
201
+ if (event.kind === "file_op") {
202
+ fileOpsCount += 1;
203
+ const action = toStringValue(p.action);
204
+ if (action === "create" || action === "edit" || action === "delete") {
205
+ const target = getFileTarget(event);
206
+ if (target)
207
+ editedFiles.add(target);
208
+ }
209
+ }
210
+ if (event.kind === "diff_summary")
211
+ diffSummaryCount += 1;
212
+ if (event.kind === "artifact_created" && isOutputEvent(event))
213
+ outputArtifacts += 1;
214
+ const usage = getTokenUsage(event);
215
+ if (usage) {
216
+ tokenTotal += usage.total_tokens;
217
+ tokenCost += usage.estimated_cost_usd ?? 0;
218
+ }
219
+ }
220
+ const repeatedCallCount = [...callKeyCount.values()].reduce((sum, count) => {
221
+ return sum + (count > repeatedCallThreshold ? count - repeatedCallThreshold : 0);
222
+ }, 0);
223
+ const repeatedFileCheckCount = [...fileCheckCount.entries()].reduce((sum, [target, count]) => {
224
+ if (count <= repeatedCheckThreshold)
225
+ return sum;
226
+ if (editedFiles.has(target))
227
+ return sum;
228
+ return sum + (count - repeatedCheckThreshold);
229
+ }, 0);
230
+ const deliverableDelta = fileOpsCount + diffSummaryCount + outputArtifacts;
231
+ const lowDeliverableWithHighToken = tokenTotal > 0 && tokenTotal > 2000 && deliverableDelta <= 1 ? 1 : 0;
232
+ const verificationLoop = verificationFailCount + verificationUnknownCount >= 3 ? 1 : 0;
233
+ const riskScoreRaw = highRiskSignals * 12 + verificationFailCount * 16 + Math.min(20, hotspotScore);
234
+ const efficiencyScoreRaw = repeatedCallCount * 10 + repeatedFileCheckCount * 12 + verificationLoop * 10 + lowDeliverableWithHighToken * 14;
235
+ const stabilityRaw = 100 - (verificationFailCount * 12 + verificationUnknownCount * 5 + repeatedCallCount * 2);
236
+ const riskScore = Math.max(0, Math.min(100, Math.round(riskScoreRaw)));
237
+ const efficiencyWasteScore = Math.max(0, Math.min(100, Math.round(efficiencyScoreRaw)));
238
+ const stabilityScore = Math.max(0, Math.min(100, Math.round(stabilityRaw)));
239
+ let template = "context_retrieval_limit";
240
+ if (riskScore >= 65 || (highRiskSignals >= 3 && verificationFailCount > 0))
241
+ template = "high_risk_guardrail";
242
+ else if (verificationLoop)
243
+ template = "verification_loop_control";
244
+ else if (repeatedFileCheckCount > 0)
245
+ template = "file_check_dedup";
246
+ else if (hotspotScore >= 8)
247
+ template = "hotspot_change_gate";
248
+ if (evidence.length === 0) {
249
+ const fallbackEvent = bucket.events.find((event) => event.kind === "tool_call" || event.kind === "file_op");
250
+ if (fallbackEvent)
251
+ evidence.push({ event_id: fallbackEvent.id, reason: "behavioral_pattern", file: fallbackEvent.scope?.file });
252
+ }
253
+ return {
254
+ features: {
255
+ intent_id: bucket.intent_id,
256
+ intent_title: bucket.intent_title,
257
+ risk_score: riskScore,
258
+ efficiency_waste_score: efficiencyWasteScore,
259
+ stability_score: stabilityScore,
260
+ high_risk_signals: highRiskSignals,
261
+ verification_fail_count: verificationFailCount,
262
+ verification_unknown_count: verificationUnknownCount,
263
+ hotspot_score: Number(hotspotScore.toFixed(2)),
264
+ repeated_call_count: repeatedCallCount,
265
+ repeated_file_check_count: repeatedFileCheckCount,
266
+ file_ops_count: fileOpsCount,
267
+ token_total: tokenTotal,
268
+ },
269
+ template,
270
+ evidence: evidence.slice(0, 12),
271
+ thresholdHints: {
272
+ repeated_call_threshold: repeatedCallThreshold,
273
+ repeated_check_threshold: repeatedCheckThreshold,
274
+ verification_loop_threshold: 3,
275
+ low_delta_token_threshold: 2000,
276
+ estimated_cost_usd: Number(tokenCost.toFixed(6)),
277
+ },
278
+ };
279
+ }
280
+ function valueClaimsForTemplate(template, features) {
281
+ const risk_mitigation = [];
282
+ const efficiency_improvement = [];
283
+ const quality_standardization = [];
284
+ if (template === "high_risk_guardrail" || features.verification_fail_count > 0 || features.high_risk_signals > 0) {
285
+ risk_mitigation.push("Mitigates high-risk regressions by enforcing stronger verification gates.");
286
+ }
287
+ if (template === "file_check_dedup" || template === "context_retrieval_limit" || features.repeated_call_count > 0) {
288
+ efficiency_improvement.push("Reduces unnecessary repeated calls/checks by applying bounded retrieval guardrails.");
289
+ }
290
+ quality_standardization.push("Standardizes intent execution with explicit evidence-based guardrails.");
291
+ return { risk_mitigation, efficiency_improvement, quality_standardization };
292
+ }
293
+ function buildRuleSpec(template, features, strictness, focus, thresholdHints, evidence) {
294
+ const softGuardrailAction = strictness === "soft"
295
+ ? "warn_and_recommend"
296
+ : strictness === "advisory"
297
+ ? "recommend_only"
298
+ : "block_until_ack";
299
+ return {
300
+ version: 1,
301
+ template_id: template,
302
+ focus,
303
+ strictness,
304
+ intent_id: features.intent_id,
305
+ intent_title: features.intent_title,
306
+ scores: {
307
+ risk_score: features.risk_score,
308
+ efficiency_waste_score: features.efficiency_waste_score,
309
+ stability_score: features.stability_score,
310
+ },
311
+ thresholds: thresholdHints,
312
+ soft_guardrails: [
313
+ {
314
+ trigger: "repeated_file_check_without_edit",
315
+ threshold: thresholdHints.repeated_check_threshold,
316
+ action: softGuardrailAction,
317
+ recommendation: "Consolidate file inspection and defer duplicate checks until meaningful edits occur.",
318
+ },
319
+ {
320
+ trigger: "repeated_tool_call_same_target",
321
+ threshold: thresholdHints.repeated_call_threshold,
322
+ action: softGuardrailAction,
323
+ recommendation: "Cache previously gathered context and avoid repeated identical retrieval calls.",
324
+ },
325
+ ],
326
+ expected_outcomes: {
327
+ risk_reduction: features.risk_score >= 50 || features.verification_fail_count > 0,
328
+ efficiency_gain: features.efficiency_waste_score >= 35,
329
+ reduced_unnecessary_checks: features.repeated_file_check_count > 0 || features.repeated_call_count > 0,
330
+ },
331
+ evidence_refs: evidence,
332
+ };
333
+ }
334
+ function buildSkillDraft(features, template, claims, thresholdHints, evidence) {
335
+ return [
336
+ `# Skill: ${features.intent_title} Guardrail`,
337
+ "",
338
+ "## Purpose",
339
+ "Apply deterministic checks that reduce wasteful context gathering and mitigate risk in similar tasks.",
340
+ "",
341
+ "## Template",
342
+ `- ${template}`,
343
+ "",
344
+ "## Value",
345
+ ...(claims.risk_mitigation.length > 0 ? claims.risk_mitigation.map((item) => `- ${item}`) : []),
346
+ ...(claims.efficiency_improvement.length > 0 ? claims.efficiency_improvement.map((item) => `- ${item}`) : []),
347
+ ...(claims.quality_standardization.length > 0
348
+ ? claims.quality_standardization.map((item) => `- ${item}`)
349
+ : []),
350
+ "",
351
+ "## Thresholds",
352
+ `- Repeated file checks threshold: ${thresholdHints.repeated_check_threshold}`,
353
+ `- Repeated call threshold: ${thresholdHints.repeated_call_threshold}`,
354
+ `- Verification loop threshold: ${thresholdHints.verification_loop_threshold}`,
355
+ "",
356
+ "## Workflow",
357
+ "1. Gather context once and cache references.",
358
+ "2. Avoid repeated retrieval calls for the same target unless state changed.",
359
+ "3. Prioritize edits and run targeted checks only when needed.",
360
+ "4. Escalate to full verification for high-risk or failing intents.",
361
+ "",
362
+ "## Evidence anchors",
363
+ ...evidence.map((item) => `- ${item.event_id}${item.file ? ` (${item.file})` : ""}: ${item.reason}`),
364
+ "",
365
+ ].join("\n");
366
+ }
367
+ function computeConfidence(features, evidenceCount) {
368
+ const strongSignals = (features.risk_score >= 55 ? 1 : 0) +
369
+ (features.efficiency_waste_score >= 45 ? 1 : 0) +
370
+ (features.verification_fail_count > 0 ? 1 : 0) +
371
+ (features.repeated_call_count > 0 ? 1 : 0) +
372
+ (features.repeated_file_check_count > 0 ? 1 : 0);
373
+ const confidence = 0.28 + strongSignals * 0.12 + Math.min(0.24, evidenceCount * 0.03);
374
+ return Math.max(0, Math.min(0.96, Number(confidence.toFixed(2))));
375
+ }
376
+ export function generateFollowupArtifacts(events, input = {}) {
377
+ const mode = input.mode ?? "per_intent";
378
+ const strictness = input.strictness ?? "soft";
379
+ const focus = input.focus ?? "risk";
380
+ const buckets = buildIntentBuckets(events);
381
+ const repeatedCallBaseline = percentile(buckets.map((bucket) => bucket.events.filter((event) => event.kind === "tool_call").length), 0.75);
382
+ const repeatedCheckBaseline = percentile(buckets.map((bucket) => bucket.events.filter((event) => isFileCheckEvent(event)).length), 0.75);
383
+ const repeatedCallThreshold = Math.max(MAX_REPEATED_CALLS_SOFT, Math.round(repeatedCallBaseline));
384
+ const repeatedCheckThreshold = Math.max(MAX_REPEATED_CHECKS_SOFT, Math.round(repeatedCheckBaseline));
385
+ const targetBuckets = mode === "session"
386
+ ? [
387
+ {
388
+ intent_id: "session_total",
389
+ intent_title: "Session aggregate",
390
+ start_seq: buckets[0]?.start_seq ?? 0,
391
+ end_seq: buckets[buckets.length - 1]?.end_seq ?? 0,
392
+ events: buckets.flatMap((bucket) => bucket.events),
393
+ },
394
+ ]
395
+ : buckets;
396
+ const artifacts = targetBuckets
397
+ .map((bucket) => {
398
+ const { features, template, evidence, thresholdHints } = deriveIntentFeatures(bucket, repeatedCallThreshold, repeatedCheckThreshold);
399
+ const valueClaims = valueClaimsForTemplate(template, features);
400
+ const confidence = computeConfidence(features, evidence.length);
401
+ const insufficient = confidence < MIN_RULE_CONFIDENCE;
402
+ return {
403
+ intent_id: bucket.intent_id,
404
+ intent_title: bucket.intent_title,
405
+ confidence,
406
+ rule_template_id: template,
407
+ features,
408
+ value_claims: valueClaims,
409
+ evidence_refs: evidence,
410
+ rule_spec: buildRuleSpec(template, features, strictness, focus, thresholdHints, evidence),
411
+ skill_draft: buildSkillDraft(features, template, valueClaims, thresholdHints, evidence),
412
+ insufficient_evidence: insufficient,
413
+ };
414
+ })
415
+ .sort((a, b) => b.features.risk_score + b.features.efficiency_waste_score - (a.features.risk_score + a.features.efficiency_waste_score));
416
+ const highRiskIntents = artifacts.filter((artifact) => artifact.features.risk_score >= 60).length;
417
+ const highWasteIntents = artifacts.filter((artifact) => artifact.features.efficiency_waste_score >= 50).length;
418
+ const avgConfidence = artifacts.length > 0
419
+ ? Number((artifacts.reduce((sum, artifact) => sum + artifact.confidence, 0) / artifacts.length).toFixed(2))
420
+ : 0;
421
+ return {
422
+ artifacts,
423
+ summary: {
424
+ intent_count: artifacts.length,
425
+ high_risk_intents: highRiskIntents,
426
+ high_efficiency_waste_intents: highWasteIntents,
427
+ avg_confidence: avgConfidence,
428
+ },
429
+ insufficient_evidence: artifacts.length === 0 || artifacts.every((artifact) => artifact.insufficient_evidence),
430
+ };
431
+ }
432
+ export function deriveIntentTokenBreakdown(events) {
433
+ const buckets = buildIntentBuckets(events);
434
+ const intentBreakdown = [];
435
+ let totalTokens = 0;
436
+ let contextTokens = 0;
437
+ let outputTokens = 0;
438
+ let unknownTokens = 0;
439
+ let cost = 0;
440
+ for (const bucket of buckets) {
441
+ const supporting = [];
442
+ let bucketTotal = 0;
443
+ let bucketContext = 0;
444
+ let bucketOutput = 0;
445
+ let bucketUnknown = 0;
446
+ let bucketCost = 0;
447
+ const tokenEvents = bucket.events.filter((event) => event.kind === "token_usage_checkpoint");
448
+ for (const checkpoint of tokenEvents) {
449
+ const usage = getTokenUsage(checkpoint);
450
+ if (!usage)
451
+ continue;
452
+ const neighbors = bucket.events.filter((event) => event.id !== checkpoint.id &&
453
+ Math.abs(event.seq - checkpoint.seq) <= TOKEN_NEIGHBORHOOD_RADIUS_SEQ);
454
+ const contextNeighbor = neighbors.filter((event) => isContextEvent(event));
455
+ const outputNeighbor = neighbors.filter((event) => isOutputEvent(event));
456
+ let contextPart = 0;
457
+ let outputPart = 0;
458
+ let unknownPart = 0;
459
+ if (contextNeighbor.length === 0 && outputNeighbor.length === 0) {
460
+ unknownPart = usage.total_tokens;
461
+ }
462
+ else if (contextNeighbor.length > 0 && outputNeighbor.length === 0) {
463
+ contextPart = usage.total_tokens;
464
+ }
465
+ else if (outputNeighbor.length > 0 && contextNeighbor.length === 0) {
466
+ outputPart = usage.total_tokens;
467
+ }
468
+ else {
469
+ const totalWeight = contextNeighbor.length + outputNeighbor.length;
470
+ contextPart = Math.round((usage.total_tokens * contextNeighbor.length) / totalWeight);
471
+ outputPart = usage.total_tokens - contextPart;
472
+ }
473
+ supporting.push({
474
+ checkpoint_event_id: checkpoint.id,
475
+ total_tokens: usage.total_tokens,
476
+ context_tokens: contextPart,
477
+ output_tokens: outputPart,
478
+ unknown_tokens: unknownPart,
479
+ neighbor_event_ids: neighbors.slice(0, 8).map((event) => event.id),
480
+ });
481
+ bucketTotal += usage.total_tokens;
482
+ bucketContext += contextPart;
483
+ bucketOutput += outputPart;
484
+ bucketUnknown += unknownPart;
485
+ bucketCost += usage.estimated_cost_usd ?? 0;
486
+ }
487
+ intentBreakdown.push({
488
+ intent_id: bucket.intent_id,
489
+ intent_title: bucket.intent_title,
490
+ total_tokens: bucketTotal,
491
+ context_tokens: bucketContext,
492
+ output_tokens: bucketOutput,
493
+ unknown_tokens: bucketUnknown,
494
+ estimated_cost_usd: bucketCost > 0 ? Number(bucketCost.toFixed(6)) : undefined,
495
+ event_window: {
496
+ start_seq: bucket.start_seq,
497
+ end_seq: bucket.end_seq,
498
+ },
499
+ supporting_events: supporting,
500
+ });
501
+ totalTokens += bucketTotal;
502
+ contextTokens += bucketContext;
503
+ outputTokens += bucketOutput;
504
+ unknownTokens += bucketUnknown;
505
+ cost += bucketCost;
506
+ }
507
+ return {
508
+ intent_breakdown: intentBreakdown.sort((a, b) => b.total_tokens - a.total_tokens),
509
+ totals: {
510
+ total_tokens: totalTokens,
511
+ context_tokens: contextTokens,
512
+ output_tokens: outputTokens,
513
+ unknown_tokens: unknownTokens,
514
+ estimated_cost_usd: cost > 0 ? Number(cost.toFixed(6)) : undefined,
515
+ },
516
+ };
517
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaolei.shawn/mcp-server",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Local-first MCP server for AI agent session auditing, with embedded dashboard.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,19 +16,6 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
- "scripts": {
20
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
21
- "build": "npm run clean && tsc",
22
- "start": "node dist/index.js start",
23
- "dev": "tsc && node dist/index.js start",
24
- "mcp": "node dist/index.js mcp",
25
- "ingest": "node dist/index.js ingest",
26
- "ingest:auto": "node dist/index.js ingest --adapter auto",
27
- "ingest:codex": "node dist/index.js ingest --adapter codex_jsonl",
28
- "ingest:cursor": "node dist/index.js ingest --adapter cursor_raw",
29
- "test": "npm run build && node --test dist/__tests__/ingest.test.js",
30
- "prepublishOnly": "npm run build"
31
- },
32
19
  "dependencies": {
33
20
  "@modelcontextprotocol/sdk": "^1.26.0",
34
21
  "zod": "^3.23.0"
@@ -61,5 +48,17 @@
61
48
  },
62
49
  "engines": {
63
50
  "node": ">=18"
51
+ },
52
+ "scripts": {
53
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
54
+ "build": "npm run clean && tsc",
55
+ "start": "node dist/index.js start",
56
+ "dev": "tsc && node dist/index.js start",
57
+ "mcp": "node dist/index.js mcp",
58
+ "ingest": "node dist/index.js ingest",
59
+ "ingest:auto": "node dist/index.js ingest --adapter auto",
60
+ "ingest:codex": "node dist/index.js ingest --adapter codex_jsonl",
61
+ "ingest:cursor": "node dist/index.js ingest --adapter cursor_raw",
62
+ "test": "npm run build && node --test dist/__tests__/*.test.js"
64
63
  }
65
- }
64
+ }