@phnx-labs/agents-cli 1.22.109 → 1.22.111
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 +48 -0
- package/dist/bootstrap.js +3 -2
- package/dist/commands/browser-sessions-picker.js +2 -1
- package/dist/commands/computer-sessions-picker.js +2 -1
- package/dist/commands/cost.js +2 -1
- package/dist/commands/fork.js +6 -4
- package/dist/commands/logs.js +2 -1
- package/dist/commands/sessions-backfill.d.ts +30 -0
- package/dist/commands/sessions-backfill.js +72 -0
- package/dist/commands/sessions-inject.d.ts +3 -3
- package/dist/commands/sessions-inject.js +5 -11
- package/dist/commands/sessions-picker.js +10 -7
- package/dist/commands/sessions-resume.d.ts +3 -1
- package/dist/commands/sessions-resume.js +32 -3
- package/dist/commands/sessions.js +44 -24
- package/dist/commands/utils.d.ts +9 -0
- package/dist/commands/utils.js +18 -0
- package/dist/commands/watchdog.js +4 -1
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +17 -1
- package/dist/lib/accounting/usage-sync.d.ts +22 -0
- package/dist/lib/accounting/usage-sync.js +70 -6
- package/dist/lib/accounting/usage.d.ts +32 -0
- package/dist/lib/accounting/usage.js +37 -2
- package/dist/lib/auth-health.js +19 -0
- package/dist/lib/claude-statusline.js +5 -0
- package/dist/lib/computer/sessions-list.js +2 -1
- package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
- package/dist/lib/daemon/auth-sync-service.js +2 -2
- package/dist/lib/daemon/daemon.js +7 -0
- package/dist/lib/daemon/session-title-service.d.ts +41 -0
- package/dist/lib/daemon/session-title-service.js +76 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
- package/dist/lib/daemon/usage-sync-service.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/daemon-ticks.js +6 -0
- package/dist/lib/fleet-shared-state.d.ts +2 -0
- package/dist/lib/hooks/install.js +14 -7
- package/dist/lib/mailbox-target.js +2 -1
- package/dist/lib/remote-agents-json.js +1 -1
- package/dist/lib/session/active.d.ts +93 -16
- package/dist/lib/session/active.js +80 -14
- package/dist/lib/session/db.d.ts +44 -1
- package/dist/lib/session/db.js +122 -17
- package/dist/lib/session/fork.d.ts +10 -2
- package/dist/lib/session/fork.js +11 -2
- package/dist/lib/session/live-metadata.js +6 -0
- package/dist/lib/session/mirror.d.ts +3 -2
- package/dist/lib/session/mirror.js +5 -2
- package/dist/lib/session/remote/remote-list.js +1 -1
- package/dist/lib/session/remote/watch.js +18 -8
- package/dist/lib/session/title.d.ts +256 -0
- package/dist/lib/session/title.js +312 -0
- package/dist/lib/session/tool-index.d.ts +5 -0
- package/dist/lib/session/tool-index.js +1 -0
- package/dist/lib/session/types.d.ts +11 -0
- package/dist/lib/startup/root-command.d.ts +2 -0
- package/dist/lib/startup/root-command.js +22 -0
- package/dist/lib/traces/sync.js +1 -0
- package/dist/lib/usage-refresh.d.ts +58 -7
- package/dist/lib/usage-refresh.js +145 -23
- package/dist/lib/watchdog/runner.d.ts +3 -0
- package/dist/lib/watchdog/runner.js +1 -0
- package/dist/session-tracker/dist/install-hook.js +4 -4
- package/package.json +1 -1
|
@@ -136,6 +136,14 @@ export function filterCachedUnattributed(sessions, attributed, alive) {
|
|
|
136
136
|
return alive(s.pid, s.startedAtMs);
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
|
|
141
|
+
* row it touched. The re-derive is load-bearing: `foldRecap` runs inside
|
|
142
|
+
* {@link getActiveSessions}, before any caller reaches the index, so a `label`
|
|
143
|
+
* or `generatedTitle` that only the index knows would otherwise never reach the
|
|
144
|
+
* shown `title` (PHNX-3797). Doing it here rather than at each of the three
|
|
145
|
+
* backfill call sites is what keeps them from drifting apart.
|
|
146
|
+
*/
|
|
139
147
|
export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
140
148
|
for (const s of sessions) {
|
|
141
149
|
if (!s.sessionId)
|
|
@@ -153,6 +161,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
153
161
|
s.firstUserMessage = m.firstUserMessage;
|
|
154
162
|
if (!s.lastUserMessage && m.lastUserMessage)
|
|
155
163
|
s.lastUserMessage = m.lastUserMessage;
|
|
164
|
+
if (!s.generatedTitle && m.generatedTitle)
|
|
165
|
+
s.generatedTitle = m.generatedTitle;
|
|
156
166
|
if (!s.ticket && m.ticketId)
|
|
157
167
|
s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
|
|
158
168
|
if (!s.pr && m.prUrl)
|
|
@@ -181,6 +191,7 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
181
191
|
if (!Number.isNaN(ts))
|
|
182
192
|
s.lastActivityMs = ts;
|
|
183
193
|
}
|
|
194
|
+
applyRecap(s);
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
function loadBackfillMetaFor(sessions) {
|
|
@@ -2119,7 +2130,8 @@ function recapLine(s, max = 120) {
|
|
|
2119
2130
|
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
|
|
2120
2131
|
}
|
|
2121
2132
|
/**
|
|
2122
|
-
*
|
|
2133
|
+
* The headline ladder (PHNX-3797): an explicit `/rename` label, then the
|
|
2134
|
+
* daemon-generated title, then the classified user prompt.
|
|
2123
2135
|
*
|
|
2124
2136
|
* The prompt rung classifies the RAW latest genuine user turn, not the
|
|
2125
2137
|
* already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
|
|
@@ -2128,6 +2140,13 @@ function recapLine(s, max = 120) {
|
|
|
2128
2140
|
* that scaffolding as the user's words. It also never saw the row's attachments,
|
|
2129
2141
|
* so an image-only turn could not be recognized. A row that has already been
|
|
2130
2142
|
* merged with a daemon-folded {@link ActiveSession.request} uses it directly.
|
|
2143
|
+
*
|
|
2144
|
+
* The agent's last transcript line is deliberately NOT a rung. It is still
|
|
2145
|
+
* returned as `lastAgentLine` — and still shown, in the separate live
|
|
2146
|
+
* preview/activity slot — but a rolling monologue is not what the session IS,
|
|
2147
|
+
* and using it as the headline is the defect this ladder fixes. A session the
|
|
2148
|
+
* titler has not reached yet falls back to the classified user prompt, never
|
|
2149
|
+
* to the agent's last line.
|
|
2131
2150
|
*/
|
|
2132
2151
|
export function deriveSessionRecap(row) {
|
|
2133
2152
|
const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
|
|
@@ -2144,6 +2163,7 @@ export function deriveSessionRecap(row) {
|
|
|
2144
2163
|
const userPromptClean = tidied?.headline ?? fallback?.clean;
|
|
2145
2164
|
const userPromptKind = tidied?.kind ?? fallback?.kind;
|
|
2146
2165
|
const label = recapLine(row.label);
|
|
2166
|
+
const generated = recapLine(row.generatedTitle);
|
|
2147
2167
|
const prompt = recapLine(userPromptClean || row.topic);
|
|
2148
2168
|
let title;
|
|
2149
2169
|
let recapSource;
|
|
@@ -2151,9 +2171,9 @@ export function deriveSessionRecap(row) {
|
|
|
2151
2171
|
title = label;
|
|
2152
2172
|
recapSource = 'label';
|
|
2153
2173
|
}
|
|
2154
|
-
else if (
|
|
2155
|
-
title =
|
|
2156
|
-
recapSource = '
|
|
2174
|
+
else if (generated) {
|
|
2175
|
+
title = generated;
|
|
2176
|
+
recapSource = 'generated';
|
|
2157
2177
|
}
|
|
2158
2178
|
else if (prompt) {
|
|
2159
2179
|
title = prompt;
|
|
@@ -2168,18 +2188,63 @@ export function deriveSessionRecap(row) {
|
|
|
2168
2188
|
...(tidied ? { request: tidied } : {}),
|
|
2169
2189
|
};
|
|
2170
2190
|
}
|
|
2191
|
+
/**
|
|
2192
|
+
* The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
|
|
2193
|
+
* agent message, ranked so the operator sees a block before generic chatter —
|
|
2194
|
+
* a pending question, then a needs-you wait (plan review / permission /
|
|
2195
|
+
* input-required), then the current activity (the agent's latest line). Returns
|
|
2196
|
+
* `undefined` only when there is nothing recent to show at all.
|
|
2197
|
+
*
|
|
2198
|
+
* Pure over the fields it reads so the ranking is unit-tested directly; folded
|
|
2199
|
+
* onto every row by {@link applyRecap} beside the {@link deriveSessionRecap} title.
|
|
2200
|
+
*/
|
|
2201
|
+
export function deriveImportantMessage(row) {
|
|
2202
|
+
// The agent's latest words — the fallback text for every kind. `lastAgentLine`
|
|
2203
|
+
// is already trimmed/capped; `preview` (a live tool/turn line) is not.
|
|
2204
|
+
const recent = recapLine(row.preview) ?? row.lastAgentLine;
|
|
2205
|
+
// A pending question is the single most important thing an agent can be saying.
|
|
2206
|
+
if (row.awaitingReason === 'question' || row.question) {
|
|
2207
|
+
const text = recapLine(row.question?.text) ?? recent;
|
|
2208
|
+
if (text)
|
|
2209
|
+
return { text, kind: 'question' };
|
|
2210
|
+
}
|
|
2211
|
+
// Blocked on the operator: a plan review, a permission decision, or a bare
|
|
2212
|
+
// input-required wait. The "needs you" case the Fleet has to make loud.
|
|
2213
|
+
const needsYou = row.awaitingReason === 'plan_review'
|
|
2214
|
+
|| row.awaitingReason === 'permission'
|
|
2215
|
+
|| row.status === 'input_required'
|
|
2216
|
+
|| row.activity === 'waiting_input';
|
|
2217
|
+
if (needsYou) {
|
|
2218
|
+
const fallback = row.awaitingReason === 'plan_review'
|
|
2219
|
+
? 'Waiting on plan review'
|
|
2220
|
+
: row.awaitingReason === 'permission'
|
|
2221
|
+
? 'Waiting on a permission decision'
|
|
2222
|
+
: 'Waiting for you';
|
|
2223
|
+
return { text: recent ?? fallback, kind: 'needs_you' };
|
|
2224
|
+
}
|
|
2225
|
+
// Nothing blocking — just what the agent is doing now.
|
|
2226
|
+
if (recent)
|
|
2227
|
+
return { text: recent, kind: 'activity' };
|
|
2228
|
+
return undefined;
|
|
2229
|
+
}
|
|
2230
|
+
/** Write one row's recap fields from its current label/generatedTitle/topic/tail. */
|
|
2231
|
+
function applyRecap(s) {
|
|
2232
|
+
const recap = deriveSessionRecap(s);
|
|
2233
|
+
s.title = recap.title;
|
|
2234
|
+
s.recapSource = recap.recapSource;
|
|
2235
|
+
s.userPromptClean = recap.userPromptClean;
|
|
2236
|
+
s.userPromptKind = recap.userPromptKind;
|
|
2237
|
+
s.lastAgentLine = recap.lastAgentLine;
|
|
2238
|
+
if (recap.request)
|
|
2239
|
+
s.request = recap.request;
|
|
2240
|
+
// The secondary line rides the same fold as the headline (PHNX-3797), so every
|
|
2241
|
+
// consumer that reads `title` off a row reads `importantMessage` beside it.
|
|
2242
|
+
s.importantMessage = deriveImportantMessage(s);
|
|
2243
|
+
}
|
|
2171
2244
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
2172
2245
|
export function foldRecap(rows) {
|
|
2173
|
-
for (const s of rows)
|
|
2174
|
-
|
|
2175
|
-
s.title = recap.title;
|
|
2176
|
-
s.recapSource = recap.recapSource;
|
|
2177
|
-
s.userPromptClean = recap.userPromptClean;
|
|
2178
|
-
s.userPromptKind = recap.userPromptKind;
|
|
2179
|
-
s.lastAgentLine = recap.lastAgentLine;
|
|
2180
|
-
if (recap.request)
|
|
2181
|
-
s.request = recap.request;
|
|
2182
|
-
}
|
|
2246
|
+
for (const s of rows)
|
|
2247
|
+
applyRecap(s);
|
|
2183
2248
|
}
|
|
2184
2249
|
/**
|
|
2185
2250
|
* Project a {@link SessionPhase} from the finalized {@link ActiveStatus} (PHNX-2484).
|
|
@@ -2233,6 +2298,7 @@ export function annotateOrchestratorLabels(sessions) {
|
|
|
2233
2298
|
if (!s.orchestratorSessionId)
|
|
2234
2299
|
continue;
|
|
2235
2300
|
const orch = byId.get(s.orchestratorSessionId);
|
|
2301
|
+
// ladder-exempt: the team orchestrator's LABEL, resolved before any index backfill (no generatedTitle exists yet); not a headline render.
|
|
2236
2302
|
if (orch)
|
|
2237
2303
|
s.orchestratorLabel = orch.label || orch.topic || undefined;
|
|
2238
2304
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
|
|
|
14
14
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
15
15
|
* assert against the constant instead of hardcoding a number that every bump
|
|
16
16
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
17
|
-
export declare const SCHEMA_VERSION =
|
|
17
|
+
export declare const SCHEMA_VERSION = 50;
|
|
18
18
|
/**
|
|
19
19
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
20
20
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -530,6 +530,8 @@ export interface LocalMirrorSource {
|
|
|
530
530
|
topic: string | null;
|
|
531
531
|
firstUserMessage: string | null;
|
|
532
532
|
label: string | null;
|
|
533
|
+
/** The daemon-generated headline (PHNX-3797), so a peer row shows the same title. */
|
|
534
|
+
generatedTitle: string | null;
|
|
533
535
|
lastActivity: string | null;
|
|
534
536
|
timestamp: string;
|
|
535
537
|
ticketId: string | null;
|
|
@@ -558,6 +560,8 @@ export interface MirrorSessionUpsert {
|
|
|
558
560
|
topic?: string | null;
|
|
559
561
|
firstUser?: string | null;
|
|
560
562
|
label?: string | null;
|
|
563
|
+
/** The publisher's daemon-generated headline (PHNX-3797); undefined when it has none yet. */
|
|
564
|
+
generatedTitle?: string | null;
|
|
561
565
|
lastActivity?: string | null;
|
|
562
566
|
timestamp: string;
|
|
563
567
|
ticketId?: string | null;
|
|
@@ -579,6 +583,45 @@ export interface MirrorSessionUpsert {
|
|
|
579
583
|
* row collapses with the live fan-out row under the same `machine:id` key.
|
|
580
584
|
*/
|
|
581
585
|
export declare function upsertMirrorSession(row: MirrorSessionUpsert, source: string, syncedAt: number): boolean;
|
|
586
|
+
/** One indexed session the daemon titler may generate a headline for (PHNX-3797). */
|
|
587
|
+
export interface SessionTitleCandidateRow {
|
|
588
|
+
id: string;
|
|
589
|
+
agent: string;
|
|
590
|
+
cwd: string | null;
|
|
591
|
+
project: string | null;
|
|
592
|
+
topic: string | null;
|
|
593
|
+
firstUserMessage: string | null;
|
|
594
|
+
label: string | null;
|
|
595
|
+
ticketId: string | null;
|
|
596
|
+
gitBranch: string | null;
|
|
597
|
+
generatedTitle: string | null;
|
|
598
|
+
generatedTitleKey: string | null;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The most-recently-active LOCAL sessions the titler may consider (PHNX-3797).
|
|
602
|
+
*
|
|
603
|
+
* Deliberately not "every untitled row": the caller decides which of these still
|
|
604
|
+
* needs work by comparing each row's stored `generatedTitleKey` against the key
|
|
605
|
+
* of its current user text, so a first-user-message change re-titles without a
|
|
606
|
+
* second stored copy of that text. Rows excluded here can never be titled:
|
|
607
|
+
* - a peer MIRROR row — its owning box generates its title and publishes it;
|
|
608
|
+
* - a row with no user text at all — there is nothing user-anchored to title;
|
|
609
|
+
* - a row that already carries a `/rename`-style `label`, which outranks a
|
|
610
|
+
* generated title in the ladder, so generating one would buy nothing.
|
|
611
|
+
* `sinceMs` bounds the work to sessions recent enough to still be shown.
|
|
612
|
+
*/
|
|
613
|
+
export declare function querySessionTitleCandidates(limit: number, opts?: {
|
|
614
|
+
sinceMs?: number;
|
|
615
|
+
id?: string;
|
|
616
|
+
}): SessionTitleCandidateRow[];
|
|
617
|
+
/**
|
|
618
|
+
* Persist one generated title against the source key it was derived from
|
|
619
|
+
* (PHNX-3797). `sourceKey` is what makes generation once-per-session: the next
|
|
620
|
+
* sweep sees a stored key equal to the row's current user text and skips it,
|
|
621
|
+
* and regenerates only when that text changed. Never touches a mirror row —
|
|
622
|
+
* the publishing box owns its own sessions' titles.
|
|
623
|
+
*/
|
|
624
|
+
export declare function setSessionGeneratedTitle(id: string, title: string, sourceKey: string, now?: number): boolean;
|
|
582
625
|
/**
|
|
583
626
|
* Drop peer mirror rows (and their cached digests) whose last sync predates the
|
|
584
627
|
* cutoff — the size ceiling / staleness pruner for the fleet session mirror
|
package/dist/lib/session/db.js
CHANGED
|
@@ -29,7 +29,7 @@ const DB_PATH = getSessionsDbPath();
|
|
|
29
29
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
30
30
|
* assert against the constant instead of hardcoding a number that every bump
|
|
31
31
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
32
|
-
export const SCHEMA_VERSION =
|
|
32
|
+
export const SCHEMA_VERSION = 50;
|
|
33
33
|
/**
|
|
34
34
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
35
35
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -154,7 +154,18 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
154
154
|
-- by age without touching real rows; mirror_source names the publishing
|
|
155
155
|
-- device. A row keeps a NULL mirror_synced_at once it gains a real transcript.
|
|
156
156
|
mirror_synced_at INTEGER,
|
|
157
|
-
mirror_source TEXT
|
|
157
|
+
mirror_source TEXT,
|
|
158
|
+
-- Daemon-generated session TITLE (PHNX-3797): a short technical label for what
|
|
159
|
+
-- the session worked on, produced once by the session-title daemon service and
|
|
160
|
+
-- read by every headline surface. Deliberately NOT transcript-derived, so the
|
|
161
|
+
-- scanner's upsert omits these columns entirely (an omitted column keeps its
|
|
162
|
+
-- prior value) and only the titler writes them. generated_title_key is the
|
|
163
|
+
-- hash of the user text the title was derived from, so the titler can tell
|
|
164
|
+
-- "already titled" from "the first user message changed"; generated_title_at
|
|
165
|
+
-- is when it was produced.
|
|
166
|
+
generated_title TEXT,
|
|
167
|
+
generated_title_key TEXT,
|
|
168
|
+
generated_title_at INTEGER
|
|
158
169
|
);
|
|
159
170
|
CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
160
171
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
@@ -1310,10 +1321,15 @@ function migrateSchema(db, fromVersion) {
|
|
|
1310
1321
|
// The idx_sessions_mirror_synced index is created unconditionally after this
|
|
1311
1322
|
// block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
|
|
1312
1323
|
}
|
|
1313
|
-
if (fromVersion <
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1324
|
+
if (fromVersion < 47) {
|
|
1325
|
+
// v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
|
|
1326
|
+
// launch metadata joined from the actor sidecar — never transcript-derived,
|
|
1327
|
+
// exactly like actor/initiated_by (v19) — so there is no ledger wipe: a rescan
|
|
1328
|
+
// can't backfill it and forcing a full re-parse would be pure churn. Existing
|
|
1329
|
+
// rows stay NULL until the sidecar-join fills them (the ON CONFLICT COALESCEs).
|
|
1330
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1331
|
+
if (!cols.has('phoenix_id'))
|
|
1332
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN phoenix_id TEXT`);
|
|
1317
1333
|
}
|
|
1318
1334
|
if (fromVersion < 48) {
|
|
1319
1335
|
// v47 -> v48: retain the LATEST genuine user turn beside the first
|
|
@@ -1334,15 +1350,26 @@ function migrateSchema(db, fromVersion) {
|
|
|
1334
1350
|
if (!cols.has('last_user_message'))
|
|
1335
1351
|
db.exec(`ALTER TABLE sessions ADD COLUMN last_user_message TEXT`);
|
|
1336
1352
|
}
|
|
1337
|
-
if (fromVersion <
|
|
1338
|
-
//
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1353
|
+
if (fromVersion < 49) {
|
|
1354
|
+
// v48 -> v49: account-first storage (PHNX-3940).
|
|
1355
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map(c => c.name));
|
|
1356
|
+
if (!cols.has('account_id'))
|
|
1357
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN account_id TEXT`);
|
|
1358
|
+
}
|
|
1359
|
+
if (fromVersion < 50) {
|
|
1360
|
+
// v49 -> v50: the daemon-generated session title (PHNX-3797). Additive
|
|
1361
|
+
// columns, no ledger wipe: nothing in a transcript produces them — the
|
|
1362
|
+
// session-title service writes them and the scanner's upsert never names
|
|
1363
|
+
// them — so re-parsing every indexed transcript would be pure churn. Rows
|
|
1364
|
+
// stay NULL until the titler reaches them, which the recap ladder handles by
|
|
1365
|
+
// falling back to the user's own first message.
|
|
1343
1366
|
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1344
|
-
if (!cols.has('
|
|
1345
|
-
db.exec(`ALTER TABLE sessions ADD COLUMN
|
|
1367
|
+
if (!cols.has('generated_title'))
|
|
1368
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN generated_title TEXT`);
|
|
1369
|
+
if (!cols.has('generated_title_key'))
|
|
1370
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_key TEXT`);
|
|
1371
|
+
if (!cols.has('generated_title_at'))
|
|
1372
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_at INTEGER`);
|
|
1346
1373
|
}
|
|
1347
1374
|
}
|
|
1348
1375
|
/**
|
|
@@ -2753,6 +2780,7 @@ function rowToMeta(row) {
|
|
|
2753
2780
|
topic: row.topic ?? undefined,
|
|
2754
2781
|
firstUserMessage: row.first_user_message ?? undefined,
|
|
2755
2782
|
lastUserMessage: row.last_user_message ?? undefined,
|
|
2783
|
+
generatedTitle: row.generated_title ?? undefined,
|
|
2756
2784
|
label: row.label ?? undefined,
|
|
2757
2785
|
isTeamOrigin: row.is_team_origin === 1,
|
|
2758
2786
|
prUrl: row.pr_url ?? undefined,
|
|
@@ -3552,7 +3580,7 @@ export function writeSessionTimeline(entry) {
|
|
|
3552
3580
|
export function queryLocalOriginSessionsForMirror(self, limit) {
|
|
3553
3581
|
const rows = getDB().prepare(`
|
|
3554
3582
|
SELECT id, short_id, agent, version, machine, cwd, topic, first_user_message,
|
|
3555
|
-
label, last_activity, timestamp, ticket_id, pr_url
|
|
3583
|
+
label, generated_title, last_activity, timestamp, ticket_id, pr_url
|
|
3556
3584
|
FROM sessions
|
|
3557
3585
|
WHERE mirror_synced_at IS NULL
|
|
3558
3586
|
AND file_path IS NOT NULL AND file_path <> ''
|
|
@@ -3565,6 +3593,7 @@ export function queryLocalOriginSessionsForMirror(self, limit) {
|
|
|
3565
3593
|
return rows.map((r) => ({
|
|
3566
3594
|
id: r.id, shortId: r.short_id, agent: r.agent, version: r.version, machine: r.machine,
|
|
3567
3595
|
cwd: r.cwd, topic: r.topic, firstUserMessage: r.first_user_message, label: r.label,
|
|
3596
|
+
generatedTitle: r.generated_title,
|
|
3568
3597
|
lastActivity: r.last_activity, timestamp: r.timestamp, ticketId: r.ticket_id, prUrl: r.pr_url,
|
|
3569
3598
|
// Ride the daemon-computed summary alongside the digest so a peer renders it
|
|
3570
3599
|
// without a transcript (PHNX-3939); only a summarized session carries one.
|
|
@@ -3589,11 +3618,11 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3589
3618
|
const result = db.prepare(`
|
|
3590
3619
|
INSERT INTO sessions (
|
|
3591
3620
|
id, short_id, agent, origin, version, timestamp, last_activity, cwd,
|
|
3592
|
-
topic, first_user_message, label, message_count, file_path, scanned_at,
|
|
3621
|
+
topic, first_user_message, label, generated_title, message_count, file_path, scanned_at,
|
|
3593
3622
|
is_team_origin, ticket_id, pr_url, machine, mirror_synced_at, mirror_source
|
|
3594
3623
|
) VALUES (
|
|
3595
3624
|
@id, @short_id, @agent, 'cli', @version, @timestamp, @last_activity, @cwd,
|
|
3596
|
-
@topic, @first_user, @label, NULL, '', @scanned_at,
|
|
3625
|
+
@topic, @first_user, @label, @generated_title, NULL, '', @scanned_at,
|
|
3597
3626
|
0, @ticket_id, @pr_url, @machine, @synced_at, @source
|
|
3598
3627
|
)
|
|
3599
3628
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -3611,6 +3640,12 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3611
3640
|
-- symptom this feature fixes. A null peer label clears it so the list falls
|
|
3612
3641
|
-- back to the synced topic.
|
|
3613
3642
|
label = excluded.label,
|
|
3643
|
+
-- Same rule for the generated headline (PHNX-3797): the publishing box owns
|
|
3644
|
+
-- its sessions' titles, and this box's titler never generates for a mirror
|
|
3645
|
+
-- row, so the peer's value wins outright and a peer that has not titled the
|
|
3646
|
+
-- session yet clears it (the ladder then falls back to the synced first
|
|
3647
|
+
-- user message, never to an agent line).
|
|
3648
|
+
generated_title = excluded.generated_title,
|
|
3614
3649
|
ticket_id = COALESCE(excluded.ticket_id, sessions.ticket_id),
|
|
3615
3650
|
pr_url = COALESCE(excluded.pr_url, sessions.pr_url),
|
|
3616
3651
|
machine = excluded.machine,
|
|
@@ -3628,6 +3663,7 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3628
3663
|
topic: row.topic ?? null,
|
|
3629
3664
|
first_user: row.firstUser ?? null,
|
|
3630
3665
|
label: row.label ?? null,
|
|
3666
|
+
generated_title: row.generatedTitle ?? null,
|
|
3631
3667
|
ticket_id: row.ticketId ?? null,
|
|
3632
3668
|
pr_url: row.prUrl ?? null,
|
|
3633
3669
|
machine: row.machine,
|
|
@@ -3665,6 +3701,75 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3665
3701
|
}
|
|
3666
3702
|
return true;
|
|
3667
3703
|
}
|
|
3704
|
+
/**
|
|
3705
|
+
* The most-recently-active LOCAL sessions the titler may consider (PHNX-3797).
|
|
3706
|
+
*
|
|
3707
|
+
* Deliberately not "every untitled row": the caller decides which of these still
|
|
3708
|
+
* needs work by comparing each row's stored `generatedTitleKey` against the key
|
|
3709
|
+
* of its current user text, so a first-user-message change re-titles without a
|
|
3710
|
+
* second stored copy of that text. Rows excluded here can never be titled:
|
|
3711
|
+
* - a peer MIRROR row — its owning box generates its title and publishes it;
|
|
3712
|
+
* - a row with no user text at all — there is nothing user-anchored to title;
|
|
3713
|
+
* - a row that already carries a `/rename`-style `label`, which outranks a
|
|
3714
|
+
* generated title in the ladder, so generating one would buy nothing.
|
|
3715
|
+
* `sinceMs` bounds the work to sessions recent enough to still be shown.
|
|
3716
|
+
*/
|
|
3717
|
+
export function querySessionTitleCandidates(limit, opts = {}) {
|
|
3718
|
+
const clauses = [
|
|
3719
|
+
`mirror_synced_at IS NULL`,
|
|
3720
|
+
`(first_user_message IS NOT NULL OR topic IS NOT NULL)`,
|
|
3721
|
+
];
|
|
3722
|
+
const params = [];
|
|
3723
|
+
if (opts.id) {
|
|
3724
|
+
clauses.push(`(id = ? OR short_id = ?)`);
|
|
3725
|
+
params.push(opts.id, opts.id);
|
|
3726
|
+
}
|
|
3727
|
+
else {
|
|
3728
|
+
// An explicitly requested session is titled even when it carries a label or
|
|
3729
|
+
// is older than the window; the periodic sweep honours both bounds.
|
|
3730
|
+
clauses.push(`(label IS NULL OR trim(label) = '')`);
|
|
3731
|
+
if (opts.sinceMs != null) {
|
|
3732
|
+
clauses.push(`COALESCE(last_activity, timestamp) >= ?`);
|
|
3733
|
+
params.push(new Date(opts.sinceMs).toISOString());
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
const rows = getDB().prepare(`
|
|
3737
|
+
SELECT id, agent, cwd, project, topic, first_user_message, label, ticket_id,
|
|
3738
|
+
git_branch, generated_title, generated_title_key
|
|
3739
|
+
FROM sessions
|
|
3740
|
+
WHERE ${clauses.join(' AND ')}
|
|
3741
|
+
ORDER BY COALESCE(last_activity, timestamp) DESC
|
|
3742
|
+
LIMIT ?
|
|
3743
|
+
`).all(...params, limit);
|
|
3744
|
+
return rows.map((r) => ({
|
|
3745
|
+
id: r.id,
|
|
3746
|
+
agent: r.agent,
|
|
3747
|
+
cwd: r.cwd,
|
|
3748
|
+
project: r.project,
|
|
3749
|
+
topic: r.topic,
|
|
3750
|
+
firstUserMessage: r.first_user_message,
|
|
3751
|
+
label: r.label,
|
|
3752
|
+
ticketId: r.ticket_id,
|
|
3753
|
+
gitBranch: r.git_branch,
|
|
3754
|
+
generatedTitle: r.generated_title,
|
|
3755
|
+
generatedTitleKey: r.generated_title_key,
|
|
3756
|
+
}));
|
|
3757
|
+
}
|
|
3758
|
+
/**
|
|
3759
|
+
* Persist one generated title against the source key it was derived from
|
|
3760
|
+
* (PHNX-3797). `sourceKey` is what makes generation once-per-session: the next
|
|
3761
|
+
* sweep sees a stored key equal to the row's current user text and skips it,
|
|
3762
|
+
* and regenerates only when that text changed. Never touches a mirror row —
|
|
3763
|
+
* the publishing box owns its own sessions' titles.
|
|
3764
|
+
*/
|
|
3765
|
+
export function setSessionGeneratedTitle(id, title, sourceKey, now = Date.now()) {
|
|
3766
|
+
const result = getDB().prepare(`
|
|
3767
|
+
UPDATE sessions
|
|
3768
|
+
SET generated_title = ?, generated_title_key = ?, generated_title_at = ?
|
|
3769
|
+
WHERE id = ? AND mirror_synced_at IS NULL
|
|
3770
|
+
`).run(title, sourceKey, now, id);
|
|
3771
|
+
return result.changes > 0;
|
|
3772
|
+
}
|
|
3668
3773
|
/**
|
|
3669
3774
|
* Drop peer mirror rows (and their cached digests) whose last sync predates the
|
|
3670
3775
|
* cutoff — the size ceiling / staleness pruner for the fleet session mirror
|
|
@@ -40,8 +40,16 @@ export interface ForkRecapInput {
|
|
|
40
40
|
/** Changed-files tally so far. */
|
|
41
41
|
changes?: ForkRecapChanges;
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the human display label the caller passes in from a raw SessionMeta.
|
|
45
|
+
*
|
|
46
|
+
* Pass the session WHOLE. A caller that re-lists fields into an object literal
|
|
47
|
+
* can legally omit the optional `generatedTitle` — the type system does not
|
|
48
|
+
* flag a missing optional property — and the headline then silently degrades to
|
|
49
|
+
* `topic`. That is what the fork call site did (PHNX-3797); the rebuilt-literal
|
|
50
|
+
* shape is caught by `title.ladder-completeness.test.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function forkLabelFor(session: Pick<SessionMeta, 'label' | 'generatedTitle' | 'topic' | 'shortId'>): string;
|
|
45
53
|
/**
|
|
46
54
|
* Build the recap-seed prompt handed to the forked sibling as its opening input.
|
|
47
55
|
*
|
package/dist/lib/session/fork.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionHeadline } from './title.js';
|
|
1
2
|
/** Longest last-assistant excerpt carried into the seed — enough to convey intent
|
|
2
3
|
* without pasting a wall of text (decision: Recap, not Full digest). */
|
|
3
4
|
const LAST_LINE_CAP = 400;
|
|
@@ -9,9 +10,17 @@ function trimLastLine(text) {
|
|
|
9
10
|
return collapsed;
|
|
10
11
|
return `${collapsed.slice(0, LAST_LINE_CAP).trimEnd()}…`;
|
|
11
12
|
}
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the human display label the caller passes in from a raw SessionMeta.
|
|
15
|
+
*
|
|
16
|
+
* Pass the session WHOLE. A caller that re-lists fields into an object literal
|
|
17
|
+
* can legally omit the optional `generatedTitle` — the type system does not
|
|
18
|
+
* flag a missing optional property — and the headline then silently degrades to
|
|
19
|
+
* `topic`. That is what the fork call site did (PHNX-3797); the rebuilt-literal
|
|
20
|
+
* shape is caught by `title.ladder-completeness.test.ts`.
|
|
21
|
+
*/
|
|
13
22
|
export function forkLabelFor(session) {
|
|
14
|
-
return session
|
|
23
|
+
return sessionHeadline(session) || session.shortId;
|
|
15
24
|
}
|
|
16
25
|
/**
|
|
17
26
|
* Build the recap-seed prompt handed to the forked sibling as its opening input.
|
|
@@ -48,6 +48,12 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
48
48
|
cwd: active.cwd,
|
|
49
49
|
project: active.project ?? undefined,
|
|
50
50
|
label: active.label,
|
|
51
|
+
// Carried even though this projection only runs for a session with NO indexed
|
|
52
|
+
// row (so the value is `undefined` today): a hand-built SessionMeta that
|
|
53
|
+
// omits the rung is exactly the shape that silently degrades the headline the
|
|
54
|
+
// moment its reachability changes (PHNX-3797). Neither guard can see it here
|
|
55
|
+
// — it calls no ladder, and its fields sit on separate lines.
|
|
56
|
+
generatedTitle: active.generatedTitle,
|
|
51
57
|
topic: active.topic,
|
|
52
58
|
firstUserMessage: active.firstUserMessage,
|
|
53
59
|
version: active.version,
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* delivers fleet-wide with no operator step. The consuming device folds peer
|
|
11
11
|
* digests into its local `sessions` index as mirror rows, so the picker/list/
|
|
12
12
|
* focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
|
|
13
|
-
* only topic/label,
|
|
14
|
-
*
|
|
13
|
+
* only topic/label, the peer's daemon-generated title (PHNX-3797), a
|
|
14
|
+
* first-user-message snippet, last-activity, agent+version, cwd, ticket, and
|
|
15
|
+
* PR — and the mirror is bounded and pruned by age.
|
|
15
16
|
*
|
|
16
17
|
* Direction is deliberately the inverse of usage-sync: EVERY box publishes its
|
|
17
18
|
* own local sessions (workers are exactly where the remote sessions the personal
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* delivers fleet-wide with no operator step. The consuming device folds peer
|
|
11
11
|
* digests into its local `sessions` index as mirror rows, so the picker/list/
|
|
12
12
|
* focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
|
|
13
|
-
* only topic/label,
|
|
14
|
-
*
|
|
13
|
+
* only topic/label, the peer's daemon-generated title (PHNX-3797), a
|
|
14
|
+
* first-user-message snippet, last-activity, agent+version, cwd, ticket, and
|
|
15
|
+
* PR — and the mirror is bounded and pruned by age.
|
|
15
16
|
*
|
|
16
17
|
* Direction is deliberately the inverse of usage-sync: EVERY box publishes its
|
|
17
18
|
* own local sessions (workers are exactly where the remote sessions the personal
|
|
@@ -94,6 +95,7 @@ export async function publishSessionMirrorToSharedStore(options = {}) {
|
|
|
94
95
|
...(s.cwd ? { cwd: s.cwd } : {}),
|
|
95
96
|
...(snippet(s.topic, 200) ? { topic: snippet(s.topic, 200) } : {}),
|
|
96
97
|
...(s.label ? { label: s.label } : {}),
|
|
98
|
+
...(snippet(s.generatedTitle, 200) ? { title: snippet(s.generatedTitle, 200) } : {}),
|
|
97
99
|
...(snippet(s.firstUserMessage) ? { firstUser: snippet(s.firstUserMessage) } : {}),
|
|
98
100
|
...(s.lastActivity ? { lastActivity: s.lastActivity } : {}),
|
|
99
101
|
timestamp: s.timestamp,
|
|
@@ -171,6 +173,7 @@ function toUpsert(raw) {
|
|
|
171
173
|
topic: cap(r.topic, 400),
|
|
172
174
|
firstUser: cap(r.firstUser, SESSION_MIRROR_SNIPPET_MAX),
|
|
173
175
|
label: cap(r.label, 400),
|
|
176
|
+
generatedTitle: cap(r.title, 200),
|
|
174
177
|
lastActivity: isString(r.lastActivity) ? r.lastActivity : undefined,
|
|
175
178
|
timestamp,
|
|
176
179
|
ticketId: cap(r.ticketId, 64),
|
|
@@ -95,7 +95,7 @@ export function parseRemoteList(stdout, machine) {
|
|
|
95
95
|
* malformed/non-array JSON is an incomplete source, not an empty machine. */
|
|
96
96
|
const SAFE_RESOLVER_KEYS = new Set([
|
|
97
97
|
'id', 'shortId', 'agent', 'origin', 'timestamp', 'lastActivity', 'project',
|
|
98
|
-
'version', 'label', 'topic', 'machine',
|
|
98
|
+
'version', 'harness', 'mode', 'label', 'topic', 'machine',
|
|
99
99
|
]);
|
|
100
100
|
function isSafeResolverRow(value) {
|
|
101
101
|
if (typeof value.id !== 'string' || typeof value.shortId !== 'string'
|
|
@@ -60,6 +60,16 @@ function epochMs(value) {
|
|
|
60
60
|
export function previousSessionWatchRowKey(scope, sessionId) {
|
|
61
61
|
return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The headline for a durable history row, on the same ladder a live row uses
|
|
65
|
+
* ({@link deriveSessionRecap}): an explicit `/rename` label, then the
|
|
66
|
+
* daemon-generated title (PHNX-3797), then the daemon-folded request headline
|
|
67
|
+
* (`request.headline` — the user's own sentence with attachment noise pulled
|
|
68
|
+
* out, PHNX-3939) when the transcript cache has one, else the raw `topic`.
|
|
69
|
+
*/
|
|
70
|
+
function previousRowTitle(session, foldedHeadline) {
|
|
71
|
+
return session.label || session.generatedTitle || foldedHeadline || session.topic || undefined;
|
|
72
|
+
}
|
|
63
73
|
/** Project one durable indexed session into the same canonical watch contract
|
|
64
74
|
* as live sessions. This is the only history backfill consumed by AGI EXT. */
|
|
65
75
|
export function toPreviousSessionWatchRow(scope, session) {
|
|
@@ -86,6 +96,7 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
86
96
|
// transcript-keyed cache the live merge reads (PHNX-3939), so a closed session
|
|
87
97
|
// still shows the request it was given and what the agent did — no re-parse.
|
|
88
98
|
const folded = readSessionTimelineAny(session.id);
|
|
99
|
+
const title = previousRowTitle(session, folded?.request?.headline);
|
|
89
100
|
return {
|
|
90
101
|
context: 'recent',
|
|
91
102
|
kind: session.agent,
|
|
@@ -93,14 +104,13 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
93
104
|
sessionId: session.id,
|
|
94
105
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
95
106
|
...(session.project ? { project: session.project } : {}),
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
: session.topic ? { title: session.topic } : {}),
|
|
107
|
+
// Same headline ladder as a live row (`deriveSessionRecap`, PHNX-3797):
|
|
108
|
+
// `/rename` label → the daemon-generated title → `request.headline` (the
|
|
109
|
+
// user's own sentence with attachment noise pulled out, PHNX-3939) →
|
|
110
|
+
// the raw `topic`. See {@link previousRowTitle}.
|
|
111
|
+
...(session.label ? { label: session.label } : {}),
|
|
112
|
+
...(session.generatedTitle ? { generatedTitle: session.generatedTitle } : {}),
|
|
113
|
+
...(title ? { title } : {}),
|
|
104
114
|
...(session.topic ? { topic: session.topic } : {}),
|
|
105
115
|
...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
|
|
106
116
|
...(session.version ? { version: session.version } : {}),
|