@phnx-labs/agents-cli 1.22.25 → 1.22.26
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 +183 -0
- package/README.md +17 -2
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +43 -0
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +33 -1
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +23 -0
- package/dist/lib/hosts/passthrough.js +45 -0
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +1 -14
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +221 -13
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/package.json +1 -1
|
@@ -877,6 +877,7 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
|
|
|
877
877
|
meta: result.meta,
|
|
878
878
|
content: result.content,
|
|
879
879
|
scan,
|
|
880
|
+
events: result.events,
|
|
880
881
|
toolCalls: result.toolCalls,
|
|
881
882
|
toolIndexMode: result.toolIndexMode,
|
|
882
883
|
});
|
|
@@ -1756,7 +1757,7 @@ async function scanAntigravityIncremental(onProgress) {
|
|
|
1756
1757
|
const result = readAntigravityMeta(filePath, currentVersion);
|
|
1757
1758
|
if (result && !seen.has(result.meta.id)) {
|
|
1758
1759
|
seen.add(result.meta.id);
|
|
1759
|
-
entries.push({ meta: result.meta, content: result.content, scan });
|
|
1760
|
+
entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
|
|
1760
1761
|
}
|
|
1761
1762
|
else {
|
|
1762
1763
|
touched.push({ filePath, scan });
|
|
@@ -1802,23 +1803,28 @@ function readAntigravityMeta(filePath, currentVersion) {
|
|
|
1802
1803
|
topic: topic ? topic.slice(0, 120) : undefined,
|
|
1803
1804
|
messageCount: events.length,
|
|
1804
1805
|
};
|
|
1805
|
-
return { meta, content: contentParts.join('\n') };
|
|
1806
|
+
return { meta, content: contentParts.join('\n'), events };
|
|
1806
1807
|
}
|
|
1807
1808
|
// ---------------------------------------------------------------------------
|
|
1808
1809
|
// OpenCode
|
|
1809
1810
|
// ---------------------------------------------------------------------------
|
|
1810
1811
|
const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
|
|
1811
1812
|
let cachedOpenCodeAccount;
|
|
1812
|
-
/**
|
|
1813
|
-
|
|
1813
|
+
/**
|
|
1814
|
+
* Query the active OpenCode account email from its SQLite database.
|
|
1815
|
+
*
|
|
1816
|
+
* Takes the scan's already-open handle so a scan opens `opencode.db` exactly
|
|
1817
|
+
* once (RUSH-2210); opens its own only when called without one.
|
|
1818
|
+
*/
|
|
1819
|
+
function getOpenCodeAccount(handle) {
|
|
1814
1820
|
if (cachedOpenCodeAccount !== undefined)
|
|
1815
1821
|
return cachedOpenCodeAccount || undefined;
|
|
1816
1822
|
// Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
|
|
1817
1823
|
// works on every OS — the CLI is absent on Windows.
|
|
1818
|
-
let
|
|
1824
|
+
let owned;
|
|
1819
1825
|
try {
|
|
1820
|
-
|
|
1821
|
-
|
|
1826
|
+
const db = handle ?? (fs.existsSync(OPENCODE_DB) ? (owned = new Database(OPENCODE_DB)) : undefined);
|
|
1827
|
+
if (db) {
|
|
1822
1828
|
const row = db
|
|
1823
1829
|
.prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
|
|
1824
1830
|
.get();
|
|
@@ -1832,13 +1838,63 @@ async function getOpenCodeAccount() {
|
|
|
1832
1838
|
catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
|
|
1833
1839
|
finally {
|
|
1834
1840
|
try {
|
|
1835
|
-
|
|
1841
|
+
owned?.close();
|
|
1836
1842
|
}
|
|
1837
1843
|
catch { /* best-effort close */ }
|
|
1838
1844
|
}
|
|
1839
1845
|
cachedOpenCodeAccount = '';
|
|
1840
1846
|
return undefined;
|
|
1841
1847
|
}
|
|
1848
|
+
/**
|
|
1849
|
+
* The per-session ledger stamp for an OpenCode row: the newest write time across
|
|
1850
|
+
* the session's own row, its messages, and its parts, paired with the total byte
|
|
1851
|
+
* length of that session's message + part payloads.
|
|
1852
|
+
*
|
|
1853
|
+
* OpenCode keeps every session in ONE shared SQLite file, so that file's
|
|
1854
|
+
* mtime/size changes whenever *any* session is written. Stamping each session
|
|
1855
|
+
* with the whole-DB stat therefore invalidated every indexed session on every
|
|
1856
|
+
* scan: up to `LIMIT 1000` sessions were re-emitted into `upsertSessionsBatch`,
|
|
1857
|
+
* and its enrichment step re-opened `opencode.db` once per re-emitted entry via
|
|
1858
|
+
* `parseSession` (RUSH-2210).
|
|
1859
|
+
*
|
|
1860
|
+
* `s.time_updated` alone is NOT that session's change signal, which is the trap
|
|
1861
|
+
* this shape exists to avoid. On a real database, parts land long after the
|
|
1862
|
+
* session row was last touched — e.g. `ses_3955202dfffe…` carried
|
|
1863
|
+
* `time_updated = 1771316403087` with its newest part at `1771331512162`, over
|
|
1864
|
+
* four hours later. So the stamp maxes `time_updated` with the newest message
|
|
1865
|
+
* and part times, and pairs it with a byte total that also moves when an
|
|
1866
|
+
* existing part's `data` is rewritten in place (a streaming turn) without any
|
|
1867
|
+
* new row or timestamp.
|
|
1868
|
+
*
|
|
1869
|
+
* The byte total is a REAL size, not a repurposed counter, because
|
|
1870
|
+
* `sessions.file_size` is read back as bytes elsewhere: `ensureToolIndex` uses
|
|
1871
|
+
* it as the tool-backfill byte budget and `toolCallsForBackfill` as the 16 MiB
|
|
1872
|
+
* in-memory parser cap (`tool-index.ts`). A message/part byte total is the
|
|
1873
|
+
* honest cost of parsing that session — strictly better than the whole-DB size
|
|
1874
|
+
* this column used to hold for every OpenCode row.
|
|
1875
|
+
*
|
|
1876
|
+
* The size is `LENGTH(CAST(data AS BLOB))`, not `LENGTH(data)`: SQLite's
|
|
1877
|
+
* `LENGTH()` on a TEXT column counts CHARACTERS, so a CJK/emoji-heavy transcript
|
|
1878
|
+
* would under-report its real size by up to 4x — and this number is a byte
|
|
1879
|
+
* budget downstream.
|
|
1880
|
+
*
|
|
1881
|
+
* A degenerate row — nothing but zeros/NaN for every time AND no payload at all
|
|
1882
|
+
* — falls back to the whole-DB stat, keeping the pre-RUSH-2210 always-rescan
|
|
1883
|
+
* behavior for exactly those rows rather than pinning them to a stamp that never
|
|
1884
|
+
* changes.
|
|
1885
|
+
*/
|
|
1886
|
+
function openCodeSessionStamp(times, bytes, dbScan) {
|
|
1887
|
+
// A positive time, not merely a finite one: the SQL COALESCEs the aggregates
|
|
1888
|
+
// to 0, so `Number.isFinite` alone would accept a row that told us nothing and
|
|
1889
|
+
// this branch could never fire.
|
|
1890
|
+
const known = [times.timeUpdated, times.lastMessageAt, times.lastPartAt, times.timeCreated]
|
|
1891
|
+
.filter(t => Number.isFinite(t) && t > 0);
|
|
1892
|
+
const size = (Number.isFinite(bytes.messageBytes) ? bytes.messageBytes : 0)
|
|
1893
|
+
+ (Number.isFinite(bytes.partBytes) ? bytes.partBytes : 0);
|
|
1894
|
+
if (known.length === 0 && size === 0)
|
|
1895
|
+
return dbScan;
|
|
1896
|
+
return { fileMtimeMs: known.length === 0 ? 0 : Math.floor(Math.max(...known)), fileSize: size };
|
|
1897
|
+
}
|
|
1842
1898
|
/** Scan OpenCode sessions from its SQLite database when the DB file has changed. */
|
|
1843
1899
|
async function scanOpenCodeIncremental() {
|
|
1844
1900
|
if (!fs.existsSync(OPENCODE_DB))
|
|
@@ -1846,8 +1902,9 @@ async function scanOpenCodeIncremental() {
|
|
|
1846
1902
|
const stat = safeStatSync(OPENCODE_DB);
|
|
1847
1903
|
if (!stat)
|
|
1848
1904
|
return;
|
|
1849
|
-
// OpenCode is one big DB
|
|
1850
|
-
//
|
|
1905
|
+
// OpenCode is one big DB. Its mtime/size is the cheap "did ANYTHING change"
|
|
1906
|
+
// short-circuit for the whole harness — not the per-session stamp, which each
|
|
1907
|
+
// row carries itself (see openCodeSessionStamp, RUSH-2210).
|
|
1851
1908
|
const currentScan = {
|
|
1852
1909
|
fileMtimeMs: Math.floor(stat.mtimeMs),
|
|
1853
1910
|
fileSize: stat.size,
|
|
@@ -1856,7 +1913,6 @@ async function scanOpenCodeIncremental() {
|
|
|
1856
1913
|
if (prev && prev.fileMtimeMs === currentScan.fileMtimeMs && prev.fileSize === currentScan.fileSize) {
|
|
1857
1914
|
return;
|
|
1858
1915
|
}
|
|
1859
|
-
const account = await getOpenCodeAccount();
|
|
1860
1916
|
const currentVersion = await getCurrentAgentVersion('opencode');
|
|
1861
1917
|
// Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
|
|
1862
1918
|
// works on every OS — the CLI is absent on Windows.
|
|
@@ -1871,14 +1927,28 @@ async function scanOpenCodeIncremental() {
|
|
|
1871
1927
|
s.time_created AS time_created,
|
|
1872
1928
|
s.time_updated AS time_updated,
|
|
1873
1929
|
COALESCE(stats.message_count, 0) AS message_count,
|
|
1930
|
+
COALESCE(stats.last_message_at, 0) AS last_message_at,
|
|
1931
|
+
COALESCE(stats.message_bytes, 0) AS message_bytes,
|
|
1932
|
+
COALESCE(parts.last_part_at, 0) AS last_part_at,
|
|
1933
|
+
COALESCE(parts.part_bytes, 0) AS part_bytes,
|
|
1874
1934
|
stats.token_count AS token_count,
|
|
1875
1935
|
stats.output_tokens AS output_tokens,
|
|
1876
1936
|
COALESCE(stats.has_token_data, 0) AS has_token_data
|
|
1877
1937
|
FROM session s
|
|
1938
|
+
LEFT JOIN (
|
|
1939
|
+
SELECT
|
|
1940
|
+
session_id,
|
|
1941
|
+
MAX(time_created) AS last_part_at,
|
|
1942
|
+
SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes
|
|
1943
|
+
FROM part
|
|
1944
|
+
GROUP BY session_id
|
|
1945
|
+
) parts ON parts.session_id = s.id
|
|
1878
1946
|
LEFT JOIN (
|
|
1879
1947
|
SELECT
|
|
1880
1948
|
session_id,
|
|
1881
1949
|
COUNT(*) AS message_count,
|
|
1950
|
+
MAX(time_created) AS last_message_at,
|
|
1951
|
+
SUM(LENGTH(CAST(data AS BLOB))) AS message_bytes,
|
|
1882
1952
|
SUM(
|
|
1883
1953
|
COALESCE(json_extract(data, '$.tokens.input'), 0) +
|
|
1884
1954
|
COALESCE(json_extract(data, '$.tokens.output'), 0) +
|
|
@@ -1896,16 +1966,42 @@ async function scanOpenCodeIncremental() {
|
|
|
1896
1966
|
LIMIT 1000;
|
|
1897
1967
|
`.replace(/\n/g, ' ');
|
|
1898
1968
|
db = new Database(OPENCODE_DB);
|
|
1969
|
+
const account = getOpenCodeAccount(db);
|
|
1899
1970
|
const rows = db.prepare(query).all();
|
|
1900
|
-
|
|
1901
|
-
|
|
1971
|
+
// Two passes. First derive each row's identity + per-session stamp, then
|
|
1972
|
+
// bulk-load the prior stamps in ONE query and keep only the rows that
|
|
1973
|
+
// actually changed. Skipped rows never reach upsertSessionsBatch, so they
|
|
1974
|
+
// never pay its per-entry `parseSession` re-open of this same DB.
|
|
1975
|
+
const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
|
|
1976
|
+
const candidates = rows.flatMap(row => {
|
|
1902
1977
|
const id = typeof row.id === 'string' ? row.id : '';
|
|
1903
1978
|
if (!id)
|
|
1904
|
-
|
|
1979
|
+
return [];
|
|
1980
|
+
const filePath = `${OPENCODE_DB}#${id}`;
|
|
1981
|
+
return [{
|
|
1982
|
+
row,
|
|
1983
|
+
id,
|
|
1984
|
+
filePath,
|
|
1985
|
+
scan: openCodeSessionStamp({
|
|
1986
|
+
timeUpdated: asInt(row.time_updated),
|
|
1987
|
+
timeCreated: asInt(row.time_created),
|
|
1988
|
+
lastMessageAt: asInt(row.last_message_at),
|
|
1989
|
+
lastPartAt: asInt(row.last_part_at),
|
|
1990
|
+
}, { messageBytes: asInt(row.message_bytes), partBytes: asInt(row.part_bytes) }, currentScan),
|
|
1991
|
+
}];
|
|
1992
|
+
});
|
|
1993
|
+
const priorStamps = getScanStampsForPaths(candidates.map(c => c.filePath));
|
|
1994
|
+
const changed = candidates.filter(c => {
|
|
1995
|
+
const prevStamp = priorStamps.get(c.filePath);
|
|
1996
|
+
return !prevStamp
|
|
1997
|
+
|| prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
|
|
1998
|
+
|| prevStamp.fileSize !== c.scan.fileSize;
|
|
1999
|
+
});
|
|
2000
|
+
const entries = [];
|
|
2001
|
+
for (const { row, id, filePath, scan } of changed) {
|
|
1905
2002
|
const title = typeof row.title === 'string' ? row.title : '';
|
|
1906
2003
|
const directory = typeof row.directory === 'string' ? row.directory : '';
|
|
1907
2004
|
const version = typeof row.version === 'string' ? row.version : '';
|
|
1908
|
-
const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
|
|
1909
2005
|
const timeCreated = asInt(row.time_created);
|
|
1910
2006
|
const timeUpdated = asInt(row.time_updated);
|
|
1911
2007
|
const messageCount = asInt(row.message_count);
|
|
@@ -1926,7 +2022,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1926
2022
|
lastActivity,
|
|
1927
2023
|
project: directory ? path.basename(directory) : undefined,
|
|
1928
2024
|
cwd: directory ? normalizeCwd(directory) : undefined,
|
|
1929
|
-
filePath
|
|
2025
|
+
filePath,
|
|
1930
2026
|
version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
|
|
1931
2027
|
account,
|
|
1932
2028
|
topic,
|
|
@@ -1934,7 +2030,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1934
2030
|
tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
|
|
1935
2031
|
outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
|
|
1936
2032
|
};
|
|
1937
|
-
entries.push({ meta, content: topic || '', scan
|
|
2033
|
+
entries.push({ meta, content: topic || '', scan });
|
|
1938
2034
|
}
|
|
1939
2035
|
upsertSessionsBatch(entries);
|
|
1940
2036
|
// Stamp the OpenCode DB itself so we can short-circuit on the next run.
|
|
@@ -3902,7 +3998,7 @@ async function scanCursorIncremental(onProgress) {
|
|
|
3902
3998
|
const result = readCursorMeta(filePath, currentVersion);
|
|
3903
3999
|
if (result && !seen.has(result.meta.id)) {
|
|
3904
4000
|
seen.add(result.meta.id);
|
|
3905
|
-
entries.push({ meta: result.meta, content: result.content, scan });
|
|
4001
|
+
entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
|
|
3906
4002
|
}
|
|
3907
4003
|
else {
|
|
3908
4004
|
touched.push({ filePath, scan });
|
|
@@ -3998,7 +4094,7 @@ export function readCursorMeta(filePath, currentVersion) {
|
|
|
3998
4094
|
messageCount: events.filter((event) => event.type === 'message').length,
|
|
3999
4095
|
todos: extractTodoProgressFromEvents(events),
|
|
4000
4096
|
};
|
|
4001
|
-
return { meta, content: userTexts.join('\n') };
|
|
4097
|
+
return { meta, content: userTexts.join('\n'), events };
|
|
4002
4098
|
}
|
|
4003
4099
|
// ---------------------------------------------------------------------------
|
|
4004
4100
|
// Kimi
|
|
@@ -71,6 +71,10 @@ export interface InsightFacets {
|
|
|
71
71
|
assistantTurns: number;
|
|
72
72
|
toolCount: number;
|
|
73
73
|
errorCount: number;
|
|
74
|
+
/** Deterministic evidence buckets used by the actions-forward report. */
|
|
75
|
+
frictionSignals: Record<string, number>;
|
|
76
|
+
correctionSignals: Record<string, number>;
|
|
77
|
+
automationSignals: Record<string, number>;
|
|
74
78
|
}
|
|
75
79
|
/**
|
|
76
80
|
* Compute every behavioural facet of one session from its parsed events.
|
|
@@ -124,3 +128,17 @@ export declare function topEntries(counts: Record<string, number>, limit: number
|
|
|
124
128
|
name: string;
|
|
125
129
|
count: number;
|
|
126
130
|
}>;
|
|
131
|
+
export type InsightActionCategory = 'rule' | 'skill' | 'automation' | 'product';
|
|
132
|
+
export interface InsightAction {
|
|
133
|
+
priority: 'high' | 'medium' | 'low';
|
|
134
|
+
category: InsightActionCategory;
|
|
135
|
+
action: string;
|
|
136
|
+
evidenceCount: number;
|
|
137
|
+
sampleSessionIds: string[];
|
|
138
|
+
}
|
|
139
|
+
export interface SessionFacetEvidence {
|
|
140
|
+
id: string;
|
|
141
|
+
facets: InsightFacets;
|
|
142
|
+
}
|
|
143
|
+
/** Build a stable, evidence-backed action list without exposing transcript text. */
|
|
144
|
+
export declare function buildInsightActions(sessions: SessionFacetEvidence[]): InsightAction[];
|
|
@@ -65,6 +65,7 @@ function emptyFacets() {
|
|
|
65
65
|
shellCommandsSeen: 0,
|
|
66
66
|
messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
|
|
67
67
|
toolCount: 0, errorCount: 0,
|
|
68
|
+
frictionSignals: {}, correctionSignals: {}, automationSignals: {},
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
function bump(map, key, by = 1) {
|
|
@@ -145,6 +146,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
145
146
|
// a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
|
|
146
147
|
// the number is never quietly truncated.
|
|
147
148
|
let lastAssistantTs = null;
|
|
149
|
+
let lastFailedTool = null;
|
|
148
150
|
for (const e of events) {
|
|
149
151
|
const ts = new Date(e.timestamp).getTime();
|
|
150
152
|
const hasTs = !Number.isNaN(ts);
|
|
@@ -153,13 +155,21 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
153
155
|
f.interruptions++;
|
|
154
156
|
break;
|
|
155
157
|
case 'usage':
|
|
156
|
-
// shortenModel so the label matches `agents sessions <id>` and `
|
|
158
|
+
// shortenModel so the label matches `agents sessions <id>` and `insights mix`
|
|
157
159
|
// rather than printing the raw id beside their shortened one.
|
|
158
160
|
if (e.model)
|
|
159
161
|
bump(f.models, shortenModel(e.model));
|
|
160
162
|
break;
|
|
161
163
|
case 'error':
|
|
162
164
|
bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
|
|
165
|
+
if (e.tool && e.tool === lastFailedTool)
|
|
166
|
+
bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
|
|
167
|
+
lastFailedTool = e.tool ?? null;
|
|
168
|
+
classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
|
|
169
|
+
break;
|
|
170
|
+
case 'tool_result':
|
|
171
|
+
if (e.success !== false && e.outcome !== 'error')
|
|
172
|
+
lastFailedTool = null;
|
|
163
173
|
break;
|
|
164
174
|
case 'message':
|
|
165
175
|
if (e.role === 'assistant') {
|
|
@@ -169,6 +179,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
169
179
|
}
|
|
170
180
|
if (e.role !== 'user')
|
|
171
181
|
break;
|
|
182
|
+
if (!e._synthetic)
|
|
183
|
+
classifyCorrection(e.content ?? '', f.correctionSignals);
|
|
172
184
|
if (hasTs) {
|
|
173
185
|
// Local-time hour. parse.ts falls back to `new Date()` for a record with no
|
|
174
186
|
// timestamp; those are indistinguishable here, but they are rare and would
|
|
@@ -197,6 +209,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
197
209
|
lastAssistantTs = ts;
|
|
198
210
|
const args = e.args ?? {};
|
|
199
211
|
const toolName = e.tool ?? '';
|
|
212
|
+
if (/askuserquestion/i.test(toolName))
|
|
213
|
+
classifyAskStall(args, f.correctionSignals);
|
|
200
214
|
// Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
|
|
201
215
|
// on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
|
|
202
216
|
// exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
|
|
@@ -218,6 +232,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
218
232
|
f.shellCommandsSeen++;
|
|
219
233
|
f.gitCommits += countGitOp(e.command, 'commit');
|
|
220
234
|
f.gitPushes += countGitOp(e.command, 'push');
|
|
235
|
+
classifyAutomation(e.command, f.automationSignals);
|
|
221
236
|
}
|
|
222
237
|
break;
|
|
223
238
|
}
|
|
@@ -228,6 +243,59 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
228
243
|
}
|
|
229
244
|
return f;
|
|
230
245
|
}
|
|
246
|
+
const FRICTION_PATTERNS = [
|
|
247
|
+
[/blocked by|guard(?:rail)? (?:blocked|denied)|permission denied/i, 'blocked guard'],
|
|
248
|
+
[/\b(?:ci|check|workflow)\b.*\b(?:red|fail(?:ed|ure)?)\b|\b(?:red|failed)\b.*\bci\b/i, 'CI red loop'],
|
|
249
|
+
[/merge conflict|conflict in |CONFLICT \(/i, 'merge conflict'],
|
|
250
|
+
];
|
|
251
|
+
const CORRECTION_PATTERNS = [
|
|
252
|
+
[/\b(?:continue|keep going|don'?t stop)\b/i, 'continue / keep going'],
|
|
253
|
+
[/\b(?:yes|go ahead|do it|merge it)\b/i, 'approval repeated'],
|
|
254
|
+
[/\b(?:are we done|done end.to.end|what(?:'s| is) left)\b/i, 'done end-to-end?'],
|
|
255
|
+
[/\b(?:did you merge|merged\??)\b/i, 'did you merge?'],
|
|
256
|
+
[/\bwhat(?:'s| is) next\??\b/i, "what's next?"],
|
|
257
|
+
[/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
|
|
258
|
+
[/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
|
|
259
|
+
];
|
|
260
|
+
const AUTOMATION_PATTERNS = [
|
|
261
|
+
[/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
|
|
262
|
+
[/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
|
|
263
|
+
[/\bgit (?:fetch|rebase|merge|push)\b/i, 'git reconcile recipe'],
|
|
264
|
+
[/\b(?:scp|rsync|agents ssh)\b/i, 'fleet file transfer'],
|
|
265
|
+
[/\b(?:release\.sh|deploy\.sh|npm publish)\b/i, 'release / deploy recipe'],
|
|
266
|
+
];
|
|
267
|
+
function classifyFriction(text, counts) {
|
|
268
|
+
for (const [pattern, label] of FRICTION_PATTERNS)
|
|
269
|
+
if (pattern.test(text))
|
|
270
|
+
bump(counts, label);
|
|
271
|
+
}
|
|
272
|
+
function classifyCorrection(text, counts) {
|
|
273
|
+
const normalized = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
274
|
+
for (const [pattern, label] of CORRECTION_PATTERNS)
|
|
275
|
+
if (pattern.test(normalized))
|
|
276
|
+
bump(counts, label);
|
|
277
|
+
}
|
|
278
|
+
function classifyAskStall(args, counts) {
|
|
279
|
+
const text = JSON.stringify(args).toLowerCase();
|
|
280
|
+
const categories = [
|
|
281
|
+
[/release|ship|deploy|publish/, 'Ask stall: release / ship / deploy'],
|
|
282
|
+
[/what'?s next|next step|next move/, "Ask stall: what's next?"],
|
|
283
|
+
[/merge|reconcile|rebase/, 'Ask stall: merge / reconcile'],
|
|
284
|
+
[/direction|approach|implementation/, 'Ask stall: direction / approach'],
|
|
285
|
+
];
|
|
286
|
+
for (const [pattern, label] of categories) {
|
|
287
|
+
if (pattern.test(text)) {
|
|
288
|
+
bump(counts, label);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
bump(counts, 'AskUserQuestion');
|
|
293
|
+
}
|
|
294
|
+
function classifyAutomation(command, counts) {
|
|
295
|
+
for (const [pattern, label] of AUTOMATION_PATTERNS)
|
|
296
|
+
if (pattern.test(command))
|
|
297
|
+
bump(counts, label);
|
|
298
|
+
}
|
|
231
299
|
/** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
|
|
232
300
|
export function percentile(values, p) {
|
|
233
301
|
if (values.length === 0)
|
|
@@ -298,6 +366,12 @@ export function mergeFacets(into, add) {
|
|
|
298
366
|
bump(into.slashCommands, k, v);
|
|
299
367
|
for (const [k, v] of Object.entries(add.errorCategories))
|
|
300
368
|
bump(into.errorCategories, k, v);
|
|
369
|
+
for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
|
|
370
|
+
bump(into.frictionSignals, k, v);
|
|
371
|
+
for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
|
|
372
|
+
bump(into.correctionSignals, k, v);
|
|
373
|
+
for (const [k, v] of Object.entries(add.automationSignals ?? {}))
|
|
374
|
+
bump(into.automationSignals, k, v);
|
|
301
375
|
into.interruptions += add.interruptions;
|
|
302
376
|
into.responseGaps.push(...add.responseGaps);
|
|
303
377
|
into.gapsOverCeiling += add.gapsOverCeiling;
|
|
@@ -328,3 +402,71 @@ export function topEntries(counts, limit) {
|
|
|
328
402
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
|
329
403
|
.slice(0, limit);
|
|
330
404
|
}
|
|
405
|
+
/** Build a stable, evidence-backed action list without exposing transcript text. */
|
|
406
|
+
export function buildInsightActions(sessions) {
|
|
407
|
+
const specs = [
|
|
408
|
+
{ source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
|
|
409
|
+
{ source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
|
|
410
|
+
{ source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
|
|
411
|
+
{ source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
|
|
412
|
+
{ source: 'correctionSignals', label: "what's next?", category: 'rule', action: 'State and execute the next in-scope step instead of asking the owner to steer implementation.' },
|
|
413
|
+
{ source: 'correctionSignals', label: 'check now', category: 'automation', action: 'Add bounded status polling with a terminal success or failure signal.' },
|
|
414
|
+
{ source: 'correctionSignals', label: "don't ask / just do it", category: 'rule', action: 'Reserve questions for genuine product or scope choices.' },
|
|
415
|
+
{ source: 'correctionSignals', label: 'Ask stall: release / ship / deploy', category: 'skill', action: 'Teach release workflows to carry publish, tag, rollout, and live verification as one chain.' },
|
|
416
|
+
{ source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
|
|
417
|
+
{ source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
|
|
418
|
+
{ source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
|
|
419
|
+
{ source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
|
|
420
|
+
{ source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
|
|
421
|
+
{ source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
|
|
422
|
+
{ source: 'automationSignals', label: 'PR babysitting', category: 'automation', action: 'Bundle PR checks, review collection, comment handling, and merge-on-green.' },
|
|
423
|
+
{ source: 'automationSignals', label: 'secrets unlock dance', category: 'product', action: 'Provide one headless secrets-backed command path for repeated credential operations.' },
|
|
424
|
+
{ source: 'automationSignals', label: 'git reconcile recipe', category: 'skill', action: 'Promote repeated git reconciliation commands into the canonical workflow.' },
|
|
425
|
+
{ source: 'automationSignals', label: 'fleet file transfer', category: 'product', action: 'Add a first-class fleet file transfer command with host/path validation.' },
|
|
426
|
+
{ source: 'automationSignals', label: 'release / deploy recipe', category: 'automation', action: 'Turn repeated release shell recipes into a checked-in release script.' },
|
|
427
|
+
];
|
|
428
|
+
const actions = [];
|
|
429
|
+
for (const spec of specs) {
|
|
430
|
+
let evidenceCount = 0;
|
|
431
|
+
const ids = [];
|
|
432
|
+
for (const session of sessions) {
|
|
433
|
+
const count = session.facets[spec.source]?.[spec.label] ?? 0;
|
|
434
|
+
if (count <= 0)
|
|
435
|
+
continue;
|
|
436
|
+
evidenceCount += count;
|
|
437
|
+
if (ids.length < 3)
|
|
438
|
+
ids.push(session.id.slice(0, 8));
|
|
439
|
+
}
|
|
440
|
+
if (evidenceCount === 0)
|
|
441
|
+
continue;
|
|
442
|
+
actions.push({
|
|
443
|
+
priority: evidenceCount >= 10 ? 'high' : evidenceCount >= 3 ? 'medium' : 'low',
|
|
444
|
+
category: spec.category,
|
|
445
|
+
action: spec.action,
|
|
446
|
+
evidenceCount,
|
|
447
|
+
sampleSessionIds: ids,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
let failedLoopCount = 0;
|
|
451
|
+
const failedLoopIds = [];
|
|
452
|
+
for (const session of sessions) {
|
|
453
|
+
const count = Object.entries(session.facets.frictionSignals ?? {})
|
|
454
|
+
.filter(([label]) => label.startsWith('failed tool loop:'))
|
|
455
|
+
.reduce((sum, [, value]) => sum + value, 0);
|
|
456
|
+
if (count <= 0)
|
|
457
|
+
continue;
|
|
458
|
+
failedLoopCount += count;
|
|
459
|
+
if (failedLoopIds.length < 3)
|
|
460
|
+
failedLoopIds.push(session.id.slice(0, 8));
|
|
461
|
+
}
|
|
462
|
+
if (failedLoopCount > 0) {
|
|
463
|
+
actions.push({
|
|
464
|
+
priority: failedLoopCount >= 10 ? 'high' : failedLoopCount >= 3 ? 'medium' : 'low',
|
|
465
|
+
category: 'automation',
|
|
466
|
+
action: 'Detect repeated failures of the same tool and stop the retry loop with a different recovery path.',
|
|
467
|
+
evidenceCount: failedLoopCount,
|
|
468
|
+
sampleSessionIds: failedLoopIds,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return actions.sort((a, b) => b.evidenceCount - a.evidenceCount || a.action.localeCompare(b.action));
|
|
472
|
+
}
|