liteagents 2.17.1 → 2.18.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 +105 -0
- package/package.json +1 -1
- package/packages/ampcode/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/ampcode/commands/docs-builder.md +43 -20
- package/packages/ampcode/commands/remember/friction.cjs +407 -7
- package/packages/ampcode/commands/remember.md +210 -149
- package/packages/claude/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/claude/commands/docs-builder.md +43 -20
- package/packages/claude/commands/remember/friction.cjs +407 -7
- package/packages/claude/commands/remember.md +210 -149
- package/packages/droid/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/droid/commands/docs-builder.md +43 -20
- package/packages/droid/commands/remember/friction.cjs +407 -7
- package/packages/droid/commands/remember.md +210 -149
- package/packages/opencode/command/docs-builder/docs-builder.cjs +25 -16
- package/packages/opencode/command/docs-builder.md +43 -20
- package/packages/opencode/command/remember/friction.cjs +407 -7
- package/packages/opencode/command/remember.md +210 -149
|
@@ -1412,7 +1412,7 @@ function analyzeMain(sessionsDir) {
|
|
|
1412
1412
|
stat = fs.statSync(inputPath);
|
|
1413
1413
|
} catch {
|
|
1414
1414
|
console.log(`No sessions found in ${inputPath}`);
|
|
1415
|
-
return 1
|
|
1415
|
+
return 2; // 2 = no input; 1 is reserved for the verdict below
|
|
1416
1416
|
}
|
|
1417
1417
|
|
|
1418
1418
|
if (stat.isFile()) {
|
|
@@ -1444,7 +1444,7 @@ function analyzeMain(sessionsDir) {
|
|
|
1444
1444
|
|
|
1445
1445
|
if (sessionFiles.length === 0) {
|
|
1446
1446
|
console.log(`No sessions found in ${inputPath}`);
|
|
1447
|
-
return 1
|
|
1447
|
+
return 2; // 2 = no input; 1 is reserved for the verdict below
|
|
1448
1448
|
}
|
|
1449
1449
|
|
|
1450
1450
|
// Create output dir
|
|
@@ -2213,9 +2213,17 @@ function clusterCandidates(allCandidates, canonicalGroups) {
|
|
|
2213
2213
|
const SELF_RE = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
|
|
2214
2214
|
const hasContext = cl.contexts.length > 0;
|
|
2215
2215
|
const allSelf = hasContext && cl.contexts.every(q => SELF_RE.test(q || ''));
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2216
|
+
// Severity is INTENSITY, distinct from existence. Every cluster already
|
|
2217
|
+
// exists only because of an observed reaction (ANCHOR_SIGNALS), so a plain
|
|
2218
|
+
// user_correction must NOT by itself qualify as severe — that made every
|
|
2219
|
+
// cluster severe by construction and collapsed the 2x2 below into
|
|
2220
|
+
// recurrence alone (fact/drop were unreachable; measured 69/69 severe on
|
|
2221
|
+
// the real corpus). Severe = a curse, an interrupt cascade, or a tool
|
|
2222
|
+
// error corroborating the reaction. A self-correction ("wrong repo") is
|
|
2223
|
+
// never severe even with an error attached.
|
|
2224
|
+
const severe = !allSelf && (
|
|
2225
|
+
signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
|
|
2226
|
+
|| cl.errors.length > 0);
|
|
2219
2227
|
const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
|
|
2220
2228
|
let artifact;
|
|
2221
2229
|
if (recurring && severe) artifact = 'antigen';
|
|
@@ -2457,6 +2465,387 @@ function extractMain(sessionsDir) {
|
|
|
2457
2465
|
return 0;
|
|
2458
2466
|
}
|
|
2459
2467
|
|
|
2468
|
+
// =============================================================================
|
|
2469
|
+
// CLASSIFY-THEN-COUNT SUBCOMMANDS -- count/render/check/migrate-attempts (see remember.md steps 4a-4c/5)
|
|
2470
|
+
// =============================================================================
|
|
2471
|
+
|
|
2472
|
+
function antigenHash(id) { return id.split('-').pop(); }
|
|
2473
|
+
|
|
2474
|
+
/** Extracts a session's date (YYYY-MM-DD) from its id's "MMDD-HHMM-hash" suffix.
|
|
2475
|
+
* Session ids carry no year, so the run's own year is assumed; if that would put
|
|
2476
|
+
* the date in the future relative to runDate, the year is rolled back by one
|
|
2477
|
+
* (handles a session from late in the prior year being replayed early in a new
|
|
2478
|
+
* one). Returns null if the id doesn't match the expected shape. */
|
|
2479
|
+
function sessionDateFromId(id, runDate) {
|
|
2480
|
+
const tail = id.split('/').pop() || '';
|
|
2481
|
+
const m = /^(\d{2})(\d{2})-\d{4}-/.exec(tail);
|
|
2482
|
+
if (!m) return null;
|
|
2483
|
+
const [, mm, dd] = m;
|
|
2484
|
+
const runYear = parseInt(runDate.slice(0, 4), 10);
|
|
2485
|
+
let dateStr = `${runYear}-${mm}-${dd}`;
|
|
2486
|
+
if (dateStr > runDate) dateStr = `${runYear - 1}-${mm}-${dd}`;
|
|
2487
|
+
return dateStr;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
|
|
2492
|
+
* Merges classifier labels (index -> "drop" | "ag-NNN" | "new:theme" | {label:
|
|
2493
|
+
* "new:theme", rule: "<one-line rule>"}) by label, counts distinct new conversations
|
|
2494
|
+
* (one per cluster INDEX, never per hash or per group), and applies the ledger rules
|
|
2495
|
+
* mechanically. Prints the count report to stdout; writes the updated ledger to
|
|
2496
|
+
* outLedgerPath if given, else also to stdout.
|
|
2497
|
+
*/
|
|
2498
|
+
function nextAntigenId(ledger) {
|
|
2499
|
+
let max = 0;
|
|
2500
|
+
for (const e of ledger.entries) {
|
|
2501
|
+
const m = /^ag-(\d+)$/.exec(e.id);
|
|
2502
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
2503
|
+
}
|
|
2504
|
+
return `ag-${String(max + 1).padStart(3, '0')}`;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/** Builds class_hints for a freshly-created `new:` entry from the cluster's own
|
|
2508
|
+
* top_keywords (mechanical, matches how the theme label itself is derived) plus
|
|
2509
|
+
* short quote snippets, so a LATER run's classifier has real material to match a
|
|
2510
|
+
* recurrence against via class_hints -- required by Decision 1c. */
|
|
2511
|
+
function buildClassHints(cluster) {
|
|
2512
|
+
const hints = [];
|
|
2513
|
+
for (const kw of (cluster.top_keywords || []).slice(0, 4)) hints.push(kw);
|
|
2514
|
+
for (const q of (cluster.contexts || []).slice(0, 2)) hints.push(q.slice(0, 80));
|
|
2515
|
+
return hints;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
|
|
2520
|
+
*
|
|
2521
|
+
* Decision 1 (Guard B, adopted after the guard-choice escalation): `new:` labels
|
|
2522
|
+
* NEVER merge in-batch, regardless of whether two cluster indices share the same
|
|
2523
|
+
* label string. Each `new:`-labeled cluster is evaluated on its own: if the
|
|
2524
|
+
* cluster's OWN `sessions` count (friction's pre-existing lexical recurrence, not
|
|
2525
|
+
* anything from this batch's grouping) is >=2, it creates its own new ledger entry
|
|
2526
|
+
* directly; if ==1, it is written nowhere. A genuine cross-cluster recurrence of
|
|
2527
|
+
* the same mistake is instead caught on a LATER run, once the first occurrence's
|
|
2528
|
+
* entry exists and its class_hints let the classifier match the next occurrence to
|
|
2529
|
+
* it like any other existing entry -- this is the intentional recall cost of
|
|
2530
|
+
* Guard B (measured in the Decision 1b validation, not tuned around).
|
|
2531
|
+
*/
|
|
2532
|
+
/** Default run date used wherever a `runDate` arg is optional (count, migrate-attempts). */
|
|
2533
|
+
function defaultRunDate() {
|
|
2534
|
+
return new Date().toISOString().slice(0, 10);
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
/**
|
|
2538
|
+
* Pure core of `count`: merges classifier labels into the ledger and produces the
|
|
2539
|
+
* count report. Throws on malformed input (bad ledger/clusters/labels shape); does
|
|
2540
|
+
* no IO -- callers own reading/writing files.
|
|
2541
|
+
*/
|
|
2542
|
+
function countLedger(ledger, labels, clusters, runDate) {
|
|
2543
|
+
if (!ledger || !Array.isArray(ledger.entries)) throw new Error('countLedger: ledger.entries must be an array');
|
|
2544
|
+
if (!Array.isArray(clusters)) throw new Error('countLedger: clusters must be an array');
|
|
2545
|
+
if (!labels || typeof labels !== 'object') throw new Error('countLedger: labels must be an object');
|
|
2546
|
+
|
|
2547
|
+
const VALID_ID = /^ag-\d+$/;
|
|
2548
|
+
const malformed = [];
|
|
2549
|
+
const agGroups = new Map(); // ag-NNN label -> [cluster indices] (matching still merges)
|
|
2550
|
+
const newClusterIdxs = []; // `new:` clusters -- Guard B: never grouped, each stands alone
|
|
2551
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
2552
|
+
// Label shape: a bare string ("drop"|"ag-NNN"|"new:theme") for drop/ag-NNN, or
|
|
2553
|
+
// {label, rule} for "new:" -- the 4a classifier now emits the one-line rule text
|
|
2554
|
+
// for a brand-new theme in the same judgment (no separate LLM pass). Both shapes
|
|
2555
|
+
// are accepted so pre-existing bare-string labels.json fixtures keep working.
|
|
2556
|
+
const raw = labels[String(i)];
|
|
2557
|
+
const isObjLabel = raw && typeof raw === 'object';
|
|
2558
|
+
const lbl = isObjLabel ? raw.label : raw;
|
|
2559
|
+
const rule = isObjLabel ? raw.rule : undefined;
|
|
2560
|
+
const isNewLabel = typeof lbl === 'string' && lbl.startsWith('new:');
|
|
2561
|
+
const known = lbl === 'drop' || VALID_ID.test(lbl) || isNewLabel;
|
|
2562
|
+
if (!known) { malformed.push({ index: i, label: lbl }); continue; }
|
|
2563
|
+
if (lbl === 'drop') continue;
|
|
2564
|
+
if (isNewLabel) { newClusterIdxs.push({ index: i, label: lbl, rule }); continue; }
|
|
2565
|
+
if (!agGroups.has(lbl)) agGroups.set(lbl, []);
|
|
2566
|
+
agGroups.get(lbl).push(i);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
const byId = new Map(ledger.entries.map(e => [e.id, e]));
|
|
2570
|
+
const report = { matched: [], newEntries: [], droppedNew1session: [], malformed, badLedgerRef: [] };
|
|
2571
|
+
|
|
2572
|
+
for (const [label, idxs] of agGroups.entries()) {
|
|
2573
|
+
const entry = byId.get(label);
|
|
2574
|
+
if (!entry) { report.badLedgerRef.push({ label, idxs }); continue; }
|
|
2575
|
+
// Defensive: a ledger that predates the session_ids scheme entirely can be missing
|
|
2576
|
+
// the key outright (observed on a real repo, 8een -- every entry lacked it, not just
|
|
2577
|
+
// an empty array). Treat missing the same as an empty array.
|
|
2578
|
+
if (!entry.evidence.session_ids) entry.evidence.session_ids = [];
|
|
2579
|
+
const existing = new Set(entry.evidence.session_ids.map(s => antigenHash(s.id)));
|
|
2580
|
+
const before = entry.evidence.sessions;
|
|
2581
|
+
const statusBefore = entry.status;
|
|
2582
|
+
const wasEmpty = entry.evidence.session_ids.length === 0;
|
|
2583
|
+
const hadMigrationLine = (entry.history || []).some(h => h.event.startsWith('identity migration'));
|
|
2584
|
+
// TRUE first-time migration (remember.md 4c "SEED, DO NOT COUNT"): session_ids empty
|
|
2585
|
+
// going in AND no "identity migration" history line yet at all -- the entry's bare
|
|
2586
|
+
// `sessions` count predates hash tracking entirely. Seed hashes, count nothing, write
|
|
2587
|
+
// ONE "identity migration" line (not per-cluster) even if several clusters match.
|
|
2588
|
+
const isTrueMigration = wasEmpty && !hadMigrationLine;
|
|
2589
|
+
// Migration-fill sub-case: session_ids still empty going in, but an "identity
|
|
2590
|
+
// migration" line ALREADY exists (a prior run migrated with 0 matches) -> first match
|
|
2591
|
+
// after that fills session_ids without counting; counting resumes once non-empty.
|
|
2592
|
+
const isMigrationFill = wasEmpty && hadMigrationLine;
|
|
2593
|
+
const isMigrationRun = isTrueMigration || isMigrationFill;
|
|
2594
|
+
|
|
2595
|
+
let newConversations = 0;
|
|
2596
|
+
const newConvClusterIdxs = [];
|
|
2597
|
+
let recurredWhileHotCount = 0;
|
|
2598
|
+
const gatedOutClusterIdxs = [];
|
|
2599
|
+
const currentAttempt = (entry.attempts || [])[(entry.attempts || []).length - 1];
|
|
2600
|
+
for (const i of idxs) {
|
|
2601
|
+
const clusterHashes = clusters[i].session_ids.map(antigenHash);
|
|
2602
|
+
const isNew = clusterHashes.every(h => !existing.has(h));
|
|
2603
|
+
if (isNew && !isMigrationRun) {
|
|
2604
|
+
newConversations += 1;
|
|
2605
|
+
newConvClusterIdxs.push(i);
|
|
2606
|
+
// Adopted-date gate: a new conversation still counts as evidence (sessions,
|
|
2607
|
+
// hashes) regardless of date, but only counts toward recurred_while_hot if its
|
|
2608
|
+
// OWN session date is on/after the CURRENT attempt's adopted date -- a mistake
|
|
2609
|
+
// that predates the rule's current phrasing isn't a phrasing failure of it.
|
|
2610
|
+
if (entry.status === 'hot' && currentAttempt) {
|
|
2611
|
+
const sessionDate = sessionDateFromId(clusters[i].session_ids[0], runDate);
|
|
2612
|
+
if (sessionDate && sessionDate >= currentAttempt.adopted) {
|
|
2613
|
+
recurredWhileHotCount += 1;
|
|
2614
|
+
} else {
|
|
2615
|
+
gatedOutClusterIdxs.push({ index: i, sessionDate, adopted: currentAttempt.adopted });
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
for (const sid of clusters[i].session_ids) {
|
|
2620
|
+
const h = antigenHash(sid);
|
|
2621
|
+
if (!existing.has(h)) { entry.evidence.session_ids.push({ id: sid, seen: runDate }); existing.add(h); }
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (isTrueMigration) {
|
|
2625
|
+
entry.history.push({ date: runDate, event: 'identity migration — legacy count grandfathered, growth requires new hashes' });
|
|
2626
|
+
} else if (isMigrationFill) {
|
|
2627
|
+
entry.history.push({ date: runDate, event: `identity migration fill — first matching hash(es) seeded (${idxs.map(i => clusters[i].session_ids.map(antigenHash).join(',')).join(', ')}), count unchanged` });
|
|
2628
|
+
} else if (newConversations > 0) {
|
|
2629
|
+
entry.evidence.sessions += newConversations;
|
|
2630
|
+
entry.evidence.last_seen = runDate;
|
|
2631
|
+
if (entry.status === 'hot' && recurredWhileHotCount > 0) {
|
|
2632
|
+
entry.recurred_while_hot = (entry.recurred_while_hot || 0) + recurredWhileHotCount;
|
|
2633
|
+
}
|
|
2634
|
+
if (gatedOutClusterIdxs.length > 0) {
|
|
2635
|
+
entry.history.push({ date: runDate, event: `${gatedOutClusterIdxs.length} new conversation(s) counted as evidence only, not recurred_while_hot -- session date predates current attempt's adopted date (${gatedOutClusterIdxs.map(g => `${g.sessionDate} < ${g.adopted}`).join(', ')})` });
|
|
2636
|
+
}
|
|
2637
|
+
if (entry.status === 'observing' && entry.evidence.sessions >= 5) {
|
|
2638
|
+
entry.status = 'hot';
|
|
2639
|
+
entry.history.push({ date: runDate, event: `promoted to hot (${entry.evidence.sessions} sessions)` });
|
|
2640
|
+
if (entry.attempts && entry.attempts.length > 0) {
|
|
2641
|
+
entry.attempts[entry.attempts.length - 1].adopted = runDate;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
report.matched.push({ label, clusters: idxs, before, after: entry.evidence.sessions, newConversations, newConvClusterIdxs, isTrueMigration, isMigrationFill, recurredWhileHotCount, gatedOutClusterIdxs, promoted: statusBefore === 'observing' && entry.status === 'hot' });
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
for (const { index: i, label, rule } of newClusterIdxs) {
|
|
2649
|
+
const cluster = clusters[i];
|
|
2650
|
+
const combinedSessions = cluster.sessions;
|
|
2651
|
+
const hashes = cluster.session_ids.map(antigenHash);
|
|
2652
|
+
if (combinedSessions < 2) { report.droppedNew1session.push({ label, idxs: [i], sessions: combinedSessions }); continue; }
|
|
2653
|
+
// A `new:` cluster that will actually create a ledger entry requires the
|
|
2654
|
+
// classifier-authored rule text -- no placeholder fallback. Missing/empty is
|
|
2655
|
+
// reported as malformed and the entry is NOT created (see friction.cjs BUG fix).
|
|
2656
|
+
if (!rule || typeof rule !== 'string' || rule.trim() === '') {
|
|
2657
|
+
malformed.push({ index: i, label, reason: 'new: label creating an entry (sessions>=2) requires a non-empty rule' });
|
|
2658
|
+
continue;
|
|
2659
|
+
}
|
|
2660
|
+
const status = combinedSessions >= 5 ? 'hot' : 'observing';
|
|
2661
|
+
const id = nextAntigenId(ledger);
|
|
2662
|
+
const newEntry = {
|
|
2663
|
+
id,
|
|
2664
|
+
class: label.slice(4),
|
|
2665
|
+
class_hints: buildClassHints(cluster),
|
|
2666
|
+
status,
|
|
2667
|
+
rule,
|
|
2668
|
+
attempts: [{ n: 1, rule, adopted: runDate, outcome: 'active' }],
|
|
2669
|
+
evidence: {
|
|
2670
|
+
sessions: combinedSessions,
|
|
2671
|
+
session_ids: cluster.session_ids.map(sid => ({ id: sid, seen: runDate })),
|
|
2672
|
+
projects: cluster.projects || [],
|
|
2673
|
+
quotes: (cluster.contexts || []).slice(0, 2),
|
|
2674
|
+
last_seen: runDate,
|
|
2675
|
+
},
|
|
2676
|
+
recurred_while_hot: 0,
|
|
2677
|
+
history: [{ date: runDate, event: `candidate (${combinedSessions} sessions)${status === 'hot' ? ' — born hot' : ''}` }],
|
|
2678
|
+
};
|
|
2679
|
+
ledger.entries.push(newEntry);
|
|
2680
|
+
byId.set(id, newEntry);
|
|
2681
|
+
report.newEntries.push({ label, idxs: [i], sessions: combinedSessions, status, hashes, createdId: id });
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
return { ledger, report };
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
|
|
2689
|
+
* Thin IO wrapper: reads files, calls countLedger, prints the report, writes/prints
|
|
2690
|
+
* the updated ledger.
|
|
2691
|
+
*/
|
|
2692
|
+
function countMain(argv) {
|
|
2693
|
+
const [labelsPath, ledgerPath, clustersPathArg, runDateArg, outLedgerPath, reportPath] = argv;
|
|
2694
|
+
if (!labelsPath || !ledgerPath) {
|
|
2695
|
+
console.log('Usage: node friction.cjs count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath] [reportPath]');
|
|
2696
|
+
return 1;
|
|
2697
|
+
}
|
|
2698
|
+
const clustersPath = clustersPathArg || './antigen_clusters.json';
|
|
2699
|
+
const runDate = runDateArg || defaultRunDate();
|
|
2700
|
+
|
|
2701
|
+
const labels = JSON.parse(fs.readFileSync(labelsPath, 'utf8'));
|
|
2702
|
+
const clusters = JSON.parse(fs.readFileSync(clustersPath, 'utf8'));
|
|
2703
|
+
const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
|
2704
|
+
|
|
2705
|
+
const { ledger: updatedLedger, report } = countLedger(ledger, labels, clusters, runDate);
|
|
2706
|
+
|
|
2707
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2708
|
+
if (reportPath) {
|
|
2709
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
2710
|
+
}
|
|
2711
|
+
if (outLedgerPath) {
|
|
2712
|
+
fs.writeFileSync(outLedgerPath, JSON.stringify(updatedLedger, null, 2));
|
|
2713
|
+
} else {
|
|
2714
|
+
console.log(JSON.stringify(updatedLedger, null, 2));
|
|
2715
|
+
}
|
|
2716
|
+
return 0;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/**
|
|
2720
|
+
* `render <ledger.json>` -- prints the MEMORY.md "## Antigens" section from a ledger.
|
|
2721
|
+
* High = hot && sessions>=5 (quotes shown). Medium = observing && sessions 3-4.
|
|
2722
|
+
* Low = observing && sessions==2. expired/rejected/escalated/sessions<2 never render.
|
|
2723
|
+
*/
|
|
2724
|
+
function renderTier(entries, quotesShown, noneText) {
|
|
2725
|
+
if (entries.length === 0) return [`- (none — ${noneText})`];
|
|
2726
|
+
return entries.map(e => {
|
|
2727
|
+
const n = e.evidence.sessions;
|
|
2728
|
+
const p = e.evidence.projects ? e.evidence.projects.length : 0;
|
|
2729
|
+
let evidence = `${n} session${n === 1 ? '' : 's'}`;
|
|
2730
|
+
if (quotesShown && p > 0) evidence += `, ${p} project${p === 1 ? '' : 's'}`;
|
|
2731
|
+
if (quotesShown && e.evidence.quotes && e.evidence.quotes.length > 0) {
|
|
2732
|
+
evidence += ' — ' + e.evidence.quotes.slice(0, 2).map(q => `"${q}"`).join(', ');
|
|
2733
|
+
}
|
|
2734
|
+
return `- ${e.rule} (evidence: ${evidence}) — ${e.id}`;
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function renderMain(argv) {
|
|
2739
|
+
const [ledgerPath] = argv;
|
|
2740
|
+
if (!ledgerPath) { console.log('Usage: node friction.cjs render <ledger.json>'); return 1; }
|
|
2741
|
+
const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
|
2742
|
+
console.log(renderLedgerText(ledger));
|
|
2743
|
+
return 0;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
/**
|
|
2747
|
+
* I7: does `rule` equal the LAST attempt's rule (array order)? Decision 2 -- resolves
|
|
2748
|
+
* the earlier "which attempt counts as current" ambiguity by dropping the `outcome`
|
|
2749
|
+
* field from the comparison entirely: whichever attempt is last in the array IS the
|
|
2750
|
+
* current phrasing, full stop. Returns the list of mismatching entry ids.
|
|
2751
|
+
*/
|
|
2752
|
+
function checkRuleAttempt(ledger) {
|
|
2753
|
+
const bad = [];
|
|
2754
|
+
for (const entry of ledger.entries) {
|
|
2755
|
+
const attempts = entry.attempts || [];
|
|
2756
|
+
if (attempts.length === 0) continue;
|
|
2757
|
+
const last = attempts[attempts.length - 1];
|
|
2758
|
+
if (entry.rule !== last.rule) bad.push(entry.id);
|
|
2759
|
+
}
|
|
2760
|
+
return bad;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
/** I6-new: is render(ledger) byte-equal to a MEMORY.md's "## Antigens" section? */
|
|
2764
|
+
function checkRenderEquality(ledger, memoryMdText) {
|
|
2765
|
+
const rendered = renderLedgerText(ledger);
|
|
2766
|
+
const start = memoryMdText.indexOf('## Antigens');
|
|
2767
|
+
if (start === -1) return { equal: false, reason: 'no ## Antigens section in MEMORY.md' };
|
|
2768
|
+
let end = memoryMdText.indexOf('\n## ', start + 1);
|
|
2769
|
+
if (end === -1) end = memoryMdText.length;
|
|
2770
|
+
const actual = memoryMdText.slice(start, end).trimEnd();
|
|
2771
|
+
return { equal: actual === rendered, rendered, actual };
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/** Shared by renderMain and checkRenderEquality so both use the exact same bytes. */
|
|
2775
|
+
function renderLedgerText(ledger) {
|
|
2776
|
+
const high = ledger.entries.filter(e => e.status === 'hot' && e.evidence.sessions >= 5);
|
|
2777
|
+
const medium = ledger.entries.filter(e => e.status === 'observing' && e.evidence.sessions >= 3 && e.evidence.sessions <= 4);
|
|
2778
|
+
const low = ledger.entries.filter(e => e.status === 'observing' && e.evidence.sessions === 2);
|
|
2779
|
+
const lines = [];
|
|
2780
|
+
lines.push('## Antigens');
|
|
2781
|
+
lines.push('### High Confidence (loaded — applies every session)');
|
|
2782
|
+
lines.push(...renderTier(high, true, 'no class currently sits at 5+ sessions'));
|
|
2783
|
+
lines.push('');
|
|
2784
|
+
lines.push('### Medium Confidence (observing — not loaded)');
|
|
2785
|
+
lines.push(...renderTier(medium, false, 'no class currently sits at 3-4 distinct sessions'));
|
|
2786
|
+
lines.push('');
|
|
2787
|
+
lines.push('### Low Confidence (needs more data)');
|
|
2788
|
+
lines.push(...renderTier(low, false, 'no class currently sits at exactly 2 sessions'));
|
|
2789
|
+
return lines.join('\n');
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/** `check <ledger.json> [memory.md]` -- prints I7 (always) and I6-new (if a MEMORY.md
|
|
2793
|
+
* path is given) results. */
|
|
2794
|
+
function checkMain(argv) {
|
|
2795
|
+
const [ledgerPath, memoryMdPath] = argv;
|
|
2796
|
+
if (!ledgerPath) { console.log('Usage: node friction.cjs check <ledger.json> [memory.md]'); return 1; }
|
|
2797
|
+
const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
|
2798
|
+
|
|
2799
|
+
const i7bad = checkRuleAttempt(ledger);
|
|
2800
|
+
console.log(`I7 (rule == last attempt's rule): ${i7bad.length} mismatch(es)${i7bad.length ? ': ' + i7bad.join(', ') : ''}`);
|
|
2801
|
+
|
|
2802
|
+
let i6newBad = false;
|
|
2803
|
+
if (memoryMdPath) {
|
|
2804
|
+
const memText = fs.readFileSync(memoryMdPath, 'utf8');
|
|
2805
|
+
const r = checkRenderEquality(ledger, memText);
|
|
2806
|
+
i6newBad = !r.equal;
|
|
2807
|
+
console.log(`I6-new (render(ledger) byte-equal to MEMORY.md Antigens): ${r.equal ? 'EQUAL' : 'NOT EQUAL' + (r.reason ? ' (' + r.reason + ')' : '')}`);
|
|
2808
|
+
} else {
|
|
2809
|
+
console.log('I6-new: skipped (no MEMORY.md path given)');
|
|
2810
|
+
}
|
|
2811
|
+
return (i7bad.length > 0 || i6newBad) ? 1 : 0;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* `migrate-attempts <ledger.json> <outPath> [runDate]` -- one-time migration (Decision
|
|
2816
|
+
* 2): for every entry checkRuleAttempt flags, append a new attempt recording the
|
|
2817
|
+
* drifted rule text as the new current attempt, and mark the former-last attempt
|
|
2818
|
+
* "superseded" (was whatever it was before -- typically "active"). Idempotent: an
|
|
2819
|
+
* entry already satisfying I7 is untouched, so a second run is a no-op.
|
|
2820
|
+
*/
|
|
2821
|
+
function migrateAttemptsMain(argv) {
|
|
2822
|
+
const [ledgerPath, outPath, runDateArg] = argv;
|
|
2823
|
+
if (!ledgerPath || !outPath) { console.log('Usage: node friction.cjs migrate-attempts <ledger.json> <outPath> [runDate]'); return 1; }
|
|
2824
|
+
const runDate = runDateArg || defaultRunDate();
|
|
2825
|
+
const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
|
2826
|
+
|
|
2827
|
+
let migrated = 0;
|
|
2828
|
+
for (const entry of ledger.entries) {
|
|
2829
|
+
const attempts = entry.attempts || [];
|
|
2830
|
+
if (attempts.length === 0) continue;
|
|
2831
|
+
const last = attempts[attempts.length - 1];
|
|
2832
|
+
if (entry.rule === last.rule) continue; // already consistent -- no-op for this entry
|
|
2833
|
+
last.outcome = 'superseded';
|
|
2834
|
+
attempts.push({
|
|
2835
|
+
n: last.n + 1,
|
|
2836
|
+
rule: entry.rule,
|
|
2837
|
+
adopted: runDate,
|
|
2838
|
+
outcome: 'active',
|
|
2839
|
+
note: 'migration: rule text had drifted from attempt log',
|
|
2840
|
+
});
|
|
2841
|
+
migrated++;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
fs.writeFileSync(outPath, JSON.stringify(ledger, null, 2));
|
|
2845
|
+
console.log(`migrate-attempts: ${migrated} entr${migrated === 1 ? 'y' : 'ies'} migrated, wrote ${outPath}`);
|
|
2846
|
+
return 0;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2460
2849
|
// =============================================================================
|
|
2461
2850
|
// PIPELINE ENTRY POINT
|
|
2462
2851
|
// =============================================================================
|
|
@@ -2494,7 +2883,8 @@ Outputs (all in .amp/remember/friction/):
|
|
|
2494
2883
|
|
|
2495
2884
|
// Step 1: Analyze sessions
|
|
2496
2885
|
console.log('\n[1/2] Analyzing sessions...\n');
|
|
2497
|
-
analyzeMain(sessionsDir);
|
|
2886
|
+
const rc = analyzeMain(sessionsDir);
|
|
2887
|
+
if (rc === 2) return rc; // no sessions found -- stop before extractMain touches antigen_clusters.json
|
|
2498
2888
|
|
|
2499
2889
|
// Check if analysis produced output
|
|
2500
2890
|
const analysisFile = '.amp/remember/friction/friction_analysis.json';
|
|
@@ -2524,4 +2914,14 @@ Outputs (all in .amp/remember/friction/):
|
|
|
2524
2914
|
return 0;
|
|
2525
2915
|
}
|
|
2526
2916
|
|
|
2527
|
-
process.
|
|
2917
|
+
if (process.argv[2] === 'count') {
|
|
2918
|
+
process.exit(countMain(process.argv.slice(3)));
|
|
2919
|
+
} else if (process.argv[2] === 'render') {
|
|
2920
|
+
process.exit(renderMain(process.argv.slice(3)));
|
|
2921
|
+
} else if (process.argv[2] === 'check') {
|
|
2922
|
+
process.exit(checkMain(process.argv.slice(3)));
|
|
2923
|
+
} else if (process.argv[2] === 'migrate-attempts') {
|
|
2924
|
+
process.exit(migrateAttemptsMain(process.argv.slice(3)));
|
|
2925
|
+
} else {
|
|
2926
|
+
process.exit(main());
|
|
2927
|
+
}
|