@prjct.app/pi-signals 0.1.1 → 0.2.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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.2.0 (2026-10-09)
2
+
3
+ - `/signals` opens the shared docked panel, like the other prjct extensions. It lists what needs a fix first, then queued, running, flaky and resolved issues, each with component, status and sightings. An issue's detail shows the triage decision, recent observations and runs. The Daily triage row shows schedule, recent days, repositories and the repair setting. `r` queues an issue for triage again. Outside the TUI the same list prints as text instead of JSON.
4
+
5
+ ## 0.1.2 (2026-10-09)
6
+
7
+ - No system-prompt rule. signal_report no longer asks every session to watch Pi and extensions; its description says what it records and when to inspect targets first.
8
+
1
9
  ## 0.1.1 (2026-10-07)
2
10
 
3
11
  - Bind the standalone npm CLI to the Pi host's public SDK peers, which Pi intentionally omits from extension installs. Record the host location on session start and use the same bootstrap for scheduled runs.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Pi's private npm bin directory may not be on your shell PATH. In that case use `
24
24
 
25
25
  - `signal_report`: select minimal evidence, a stable signature identifying the component and failure mechanism, and one of the three signal categories. The same signature groups repeated sightings; repeated submission of the same session evidence is deduplicated.
26
26
  - `signal_inspect`: list `targets`, `issues`, or `observations`. Supply `issueId` for persisted issue observations. Pages have explicit continuation offsets; history is never trimmed.
27
- - `/signals`: show recent local issues and the configured daily time.
27
+ - `/signals`: the docked panel (shared with the other prjct extensions). What needs a fix comes first, then queued, running, flaky and resolved issues, each with its component, status and sightings. Enter shows the triage decision, recent observations and runs; the **Daily triage** row shows the schedule, recent days, repositories and repair setting. `r` queues an issue for triage again. Outside the TUI the same list prints as text.
28
28
  - `pi-signals status`, `show <id>` and `retry <id>`: inspect or explicitly requeue a signal.
29
29
  - `pi-signals once`: run queued triage/repair now. `once --scheduled` respects the daily clock and completed-day record.
30
30
  - `pi-signals schedule install`: install a macOS LaunchAgent using the absolute installed npm CLI path. An hourly OS wake checks the configured timezone; the worker runs once after the configured hour and catches up after sleep/login. There is no resident polling process inside Pi. Other platforms can schedule `once --scheduled` hourly with their OS scheduler.
package/dist/cli.mjs CHANGED
@@ -92,7 +92,13 @@ var init_store = __esm({
92
92
  return this.db.prepare("SELECT id FROM issues ORDER BY updated DESC,id LIMIT 21 OFFSET ?").all(offset).map((row) => this.get(String(row.id)));
93
93
  }
94
94
  observations(id, offset = 0, limit = 21) {
95
- return this.db.prepare("SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?").all(id, limit, offset).map((row) => ({ ...row, payload: JSON.parse(String(row.payload)) }));
95
+ return this.db.prepare("SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?").all(id, limit, offset).map((row) => ({
96
+ id: String(row.id),
97
+ issueId: String(row.issueId),
98
+ version: String(row.version),
99
+ created: Number(row.created),
100
+ payload: JSON.parse(String(row.payload))
101
+ }));
96
102
  }
97
103
  capture(value, now = Date.now()) {
98
104
  return this.transaction(() => {
@@ -154,8 +160,22 @@ var init_store = __esm({
154
160
  this.assertOwner(owner);
155
161
  this.db.exec("UPDATE issues SET status='new',lastError='Previous worker interrupted; evidence retained' WHERE status IN ('triaging','repairing'); UPDATE runs SET state='interrupted' WHERE state='running'");
156
162
  }
163
+ /** The latest daily triage days, newest first. */
164
+ days(limit = 7) {
165
+ return this.db.prepare("SELECT day,started,finished FROM schedule ORDER BY day DESC LIMIT ?").all(limit).map((row) => ({ day: String(row.day), started: Number(row.started), ...row.finished == null ? {} : { finished: Number(row.finished) } }));
166
+ }
157
167
  runs(id) {
158
- return this.db.prepare("SELECT * FROM runs WHERE issueId=? ORDER BY started").all(id).map((row) => ({ ...row, id: String(row.id), state: String(row.state), result: row.result ? JSON.parse(String(row.result)) : void 0 }));
168
+ return this.db.prepare("SELECT * FROM runs WHERE issueId=? ORDER BY started").all(id).map((row) => ({
169
+ id: String(row.id),
170
+ issueId: String(row.issueId),
171
+ revision: Number(row.revision),
172
+ phase: String(row.phase),
173
+ owner: String(row.owner),
174
+ state: String(row.state),
175
+ started: Number(row.started),
176
+ ...row.finished == null ? {} : { finished: Number(row.finished) },
177
+ result: row.result ? JSON.parse(String(row.result)) : void 0
178
+ }));
159
179
  }
160
180
  };
161
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-signals",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Local failure signals, daily agent-owned triage and verified repair candidates for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,8 @@
32
32
  "bin"
33
33
  ],
34
34
  "dependencies": {
35
- "@prjct.app/pi-secrets": "^0.2.6"
35
+ "@prjct.app/pi-secrets": "^0.2.6",
36
+ "@prjct.app/pi-tui-kit": "^0.3.5"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-ai": ">=1.0.4",
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { hideLocalPaths, prepareReport } from './report.ts';
5
5
  import { InspectSchema, OBSERVATION, ReportSchema } from './schema.ts';
6
6
  import { SignalStore } from './store.ts';
7
7
  import { rememberHost } from './host.ts';
8
+ import { brand, openPanel, panelText } from '@prjct.app/pi-tui-kit';
9
+ import { signalPanelSpec, type SignalPanelOps } from './panel.ts';
8
10
 
9
11
  const defaults = { config, targets, protect: protectOutboundData, databasePath };
10
12
  export type SignalsDependencies = Partial<typeof defaults>;
@@ -20,8 +22,8 @@ export function installSignals(pi: ExtensionAPI, dependencies: SignalsDependenci
20
22
  };
21
23
  pi.registerTool({
22
24
  name: 'signal_report', label: 'Record failure signal',
23
- description: 'Record a local issue in the global .prjct database for an installed extension or Pi: latency regression, crash, or concrete response-quality regression. Supply a stable defect signature, exact session evidence, reproduction steps, expected and actual behavior. No GitHub or model request occurs during capture.',
24
- promptGuidelines: ['When you detect a latency regression, crash, or concrete response-quality regression in Pi or an installed extension, record minimal evidence with signal_report, then continue the original task. Use signal_inspect for targets. Detection is a signal, not proof of causality; the active agent and later triage decide.'],
25
+ // No promptGuidelines: a system-prompt rule asked every session to watch Pi, whatever the task.
26
+ description: 'Record a local issue in the global .prjct database for an installed extension or Pi: latency regression, crash, or concrete response-quality regression. Supply a stable defect signature, exact session evidence, reproduction steps, expected and actual behavior. Find targets with signal_inspect first; a detection is a signal, not proof of causality. No GitHub or model request occurs during capture; afterwards, continue the original task.',
25
27
  parameters: ReportSchema,
26
28
  async execute(_id, input, _signal, _update, ctx) {
27
29
  const target = catalog(ctx).find(target => target.id === input.target);
@@ -48,13 +50,36 @@ export function installSignals(pi: ExtensionAPI, dependencies: SignalsDependenci
48
50
  finally { store.close(); }
49
51
  },
50
52
  });
53
+ const withStore = <T>(read: (store: SignalStore) => T): T => {
54
+ const store = new SignalStore(deps.databasePath());
55
+ try { return read(store); } finally { store.close(); }
56
+ };
57
+ /** One snapshot per panel draw; a change clears it. Every issue, not one page. */
58
+ const panelOps = (): SignalPanelOps => {
59
+ const cache: { issues?: ReturnType<SignalStore['list']> } = {};
60
+ const all = (store: SignalStore) => {
61
+ const pages = (offset: number): ReturnType<SignalStore['list']> => {
62
+ const rows = store.list(offset);
63
+ return rows.length > 20 ? [...rows.slice(0, 20), ...pages(offset + 20)] : rows;
64
+ };
65
+ return pages(0);
66
+ };
67
+ return {
68
+ config: () => deps.config(),
69
+ issues: () => (cache.issues ??= withStore(all)),
70
+ observations: id => withStore(store => store.observations(id, 0, 50)),
71
+ runs: id => withStore(store => store.runs(id)),
72
+ days: () => withStore(store => store.days()),
73
+ retry: id => { cache.issues = undefined; return withStore(store => store.retry(id)); },
74
+ };
75
+ };
51
76
  pi.registerCommand('signals', {
52
- description: 'Show local issues and daily triage configuration',
77
+ description: brand('signals: local issues in Pi and extensions, what to fix, and the daily triage'),
53
78
  async handler(_args, ctx) {
54
79
  try {
55
- const settings = deps.config(), store = new SignalStore(deps.databasePath());
56
- try { ctx.ui.notify(JSON.stringify({ enabled: settings.enabled, daily: `${settings.hour}:${String(settings.minute).padStart(2, '0')} ${settings.timezone}`, issues: page(store.list(), 0) }), 'info'); }
57
- finally { store.close(); }
80
+ const spec = signalPanelSpec(panelOps());
81
+ if (ctx.mode === 'tui' && ctx.hasUI && typeof ctx.ui.custom === 'function') await openPanel(ctx, spec);
82
+ else ctx.ui.notify(panelText(spec), 'info');
58
83
  } catch { ctx.ui.notify('Cannot read signals configuration or database.', 'error'); }
59
84
  },
60
85
  });
package/src/panel.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { SYMBOL, ago, type PanelAction, type PanelDetail, type PanelItem, type PanelSpec, type Tone } from '@prjct.app/pi-tui-kit';
2
+ import type { Config } from './config.ts';
3
+ import type { LocalIssue, Status } from './store.ts';
4
+
5
+ /** What the panel reads and the one change it can make. Each call opens and closes the store. */
6
+ export type SignalPanelOps = {
7
+ readonly config: () => Config;
8
+ readonly issues: () => readonly LocalIssue[];
9
+ readonly observations: (id: string) => readonly { created: number; version: string; payload: { title: string; body: string } }[];
10
+ readonly runs: (id: string) => readonly { phase: string; state: string; started: number; finished?: number; result?: unknown }[];
11
+ readonly days: () => readonly { day: string; started: number; finished?: number }[];
12
+ /** Queue an issue for triage again; false when a worker holds it. */
13
+ readonly retry: (id: string) => boolean;
14
+ readonly now?: () => number;
15
+ };
16
+
17
+ const TRIAGE = '__triage__';
18
+ const STATUS: Readonly<Record<Status, { word: string; short: string; symbol: string; tone: Tone; rank: number }>> = {
19
+ attention: { word: 'needs a fix', short: 'fix', symbol: SYMBOL.attention, tone: 'warning', rank: 0 },
20
+ blocked: { word: 'repair blocked', short: 'blocked', symbol: SYMBOL.error, tone: 'error', rank: 1 },
21
+ needs_evidence: { word: 'needs evidence', short: 'evidence', symbol: SYMBOL.attention, tone: 'warning', rank: 2 },
22
+ new: { word: 'waiting for triage', short: 'queued', symbol: SYMBOL.idle, tone: 'accent', rank: 3 },
23
+ triaging: { word: 'triaging', short: 'triaging', symbol: SYMBOL.active, tone: 'accent', rank: 4 },
24
+ repairing: { word: 'repairing', short: 'repairing', symbol: SYMBOL.active, tone: 'accent', rank: 5 },
25
+ flaky: { word: 'flaky', short: 'flaky', symbol: SYMBOL.idle, tone: 'muted', rank: 6 },
26
+ verified: { word: 'fix verified', short: 'verified', symbol: SYMBOL.ok, tone: 'success', rank: 7 },
27
+ resolved: { word: 'resolved', short: 'resolved', symbol: SYMBOL.ok, tone: 'success', rank: 8 },
28
+ };
29
+ /** What still needs someone: a fix, a repair that failed, or more evidence. */
30
+ const TO_FIX: readonly Status[] = ['attention', 'blocked', 'needs_evidence'];
31
+ const RETRYABLE: readonly Status[] = ['attention', 'blocked', 'needs_evidence', 'flaky', 'verified', 'resolved'];
32
+
33
+ const at = (config: Config): string => `${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')} ${config.timezone}`;
34
+ const firstLine = (text: string): string => text.split('\n').map(line => line.trim()).find(Boolean) ?? '';
35
+ const short = (value: unknown): string => {
36
+ if (value === undefined || value === null) return '';
37
+ const record = value as { status?: unknown; explanation?: unknown; verdict?: unknown };
38
+ return [record.status ?? record.verdict, record.explanation].filter(part => typeof part === 'string' && part).join(' · ').slice(0, 200);
39
+ };
40
+
41
+ export function signalPanelSpec(ops: SignalPanelOps, initial?: string): PanelSpec {
42
+ const now = (): number => ops.now?.() ?? Date.now();
43
+ const sorted = (): readonly LocalIssue[] => [...ops.issues()]
44
+ .sort((a, b) => STATUS[a.status].rank - STATUS[b.status].rank || b.updated - a.updated);
45
+ const issueOf = (item: PanelItem | undefined): LocalIssue | undefined =>
46
+ item && item.id !== TRIAGE ? ops.issues().find(issue => issue.id === item.id) : undefined;
47
+
48
+ const triageDetail = (): PanelDetail => {
49
+ const config = ops.config();
50
+ const repositories = Object.entries(config.repositories);
51
+ return {
52
+ title: 'Daily triage',
53
+ subtitle: config.enabled ? `runs every day at ${at(config)}` : 'off: signals are disabled in the global configuration',
54
+ subtitleTone: config.enabled ? 'muted' : 'warning',
55
+ fields: [
56
+ { label: 'Signals', value: config.enabled ? 'on' : 'off', tone: config.enabled ? 'success' : 'warning' },
57
+ { label: 'Repair', value: config.repair ? 'on: verified fixes are prepared in a worktree' : 'off: triage only' },
58
+ { label: 'Watching', value: config.packages.length ? config.packages.join(', ') : 'Pi and installed extensions' },
59
+ { label: 'Repositories', value: repositories.length ? repositories.map(([name, repo]) => `${name} (${repo.base})`).join(', ') : 'none configured' },
60
+ ],
61
+ sections: [
62
+ { title: 'Recent days', lines: ops.days().map(day => `${day.day} ${day.finished ? `finished ${ago(day.finished, now())}` : `started ${ago(day.started, now())}, not finished`}`) },
63
+ ...(ops.issues().length ? [] : [{ title: 'No issues yet', lines: [
64
+ 'When Pi or an installed extension shows a latency regression, a crash or a concrete response-quality regression, the agent can record it with signal_report.',
65
+ 'Recorded issues appear here and are triaged daily.',
66
+ ] }]),
67
+ ],
68
+ };
69
+ };
70
+
71
+ const issueDetail = (issue: LocalIssue): PanelDetail => {
72
+ const status = STATUS[issue.status];
73
+ const observations = [...ops.observations(issue.id)].sort((a, b) => b.created - a.created).slice(0, 5);
74
+ const runs = [...ops.runs(issue.id)].sort((a, b) => b.started - a.started).slice(0, 5);
75
+ return {
76
+ title: issue.title,
77
+ subtitle: `${issue.component} · ${issue.category}`,
78
+ fields: [
79
+ { label: 'Status', value: status.word, tone: status.tone },
80
+ { label: 'Seen', value: `${issue.sightings} ${issue.sightings === 1 ? 'time' : 'times'}` },
81
+ { label: 'First seen', value: ago(issue.created, now()) },
82
+ { label: 'Updated', value: ago(issue.updated, now()) },
83
+ ...(issue.model ? [{ label: 'Model', value: `${issue.model.provider}/${issue.model.id}` }] : []),
84
+ ...(issue.lastError ? [{ label: 'Last error', value: issue.lastError, tone: 'error' as const }] : []),
85
+ ],
86
+ sections: [
87
+ ...(issue.decision ? [{ title: `Triage: ${issue.decision.verdict}`, lines: [issue.decision.reason, ...issue.decision.evidence.map(line => `· ${line}`)] }] : []),
88
+ { title: 'Observations', lines: observations.map(item => `${ago(item.created, now())} · ${item.version} · ${firstLine(item.payload.title) || firstLine(item.payload.body)}`) },
89
+ { title: 'Runs', lines: runs.map(run => `${run.phase} ${run.state} · ${ago(run.started, now())}${short(run.result) ? ` · ${short(run.result)}` : ''}`) },
90
+ ],
91
+ };
92
+ };
93
+
94
+ const actions: PanelAction[] = [{
95
+ key: 'r', label: 'Queue for triage again',
96
+ when: item => { const issue = issueOf(item); return !!issue && RETRYABLE.includes(issue.status); },
97
+ run: (item, panel) => {
98
+ const issue = issueOf(item)!;
99
+ const queued = ops.retry(issue.id);
100
+ panel.notice(queued ? `${issue.title}: queued for the next triage.` : `${issue.title}: a worker is on it; try again later.`, queued ? 'success' : 'warning');
101
+ panel.refresh();
102
+ },
103
+ }];
104
+
105
+ return {
106
+ title: 'Signals',
107
+ summary: () => {
108
+ const issues = ops.issues(), config = ops.config();
109
+ const toFix = issues.filter(issue => TO_FIX.includes(issue.status)).length;
110
+ return [`${issues.length} ${issues.length === 1 ? 'issue' : 'issues'}`, `${toFix} to fix`,
111
+ config.enabled ? `triage ${at(config)}` : 'triage off'].join(' · ');
112
+ },
113
+ items: () => {
114
+ const config = ops.config();
115
+ // The list column is narrow: the label carries what it is, the meta one short fact.
116
+ const triage: PanelItem = { id: TRIAGE, label: 'Daily triage', symbol: config.enabled ? SYMBOL.active : SYMBOL.idle,
117
+ tone: config.enabled ? 'success' : 'warning', meta: config.enabled ? at(config).split(' ')[0] : 'off', search: config.timezone };
118
+ return [triage, ...sorted().map(issue => {
119
+ const status = STATUS[issue.status];
120
+ return { id: issue.id, label: `${issue.component} · ${issue.title}`, symbol: status.symbol, tone: status.tone,
121
+ meta: `${status.short} · ${issue.sightings}×`, search: `${issue.category} ${issue.status} ${status.word}` };
122
+ })];
123
+ },
124
+ detail: item => { const issue = issueOf(item); return issue ? issueDetail(issue) : triageDetail(); },
125
+ actions,
126
+ empty: 'No issues yet.',
127
+ initial: initial ?? sorted()[0]?.id ?? TRIAGE,
128
+ };
129
+ }
package/src/store.ts CHANGED
@@ -34,7 +34,8 @@ export class SignalStore {
34
34
  list(offset = 0) { return this.db.prepare('SELECT id FROM issues ORDER BY updated DESC,id LIMIT 21 OFFSET ?').all(offset).map(row => this.get(String(row.id))!); }
35
35
  observations(id: string, offset = 0, limit = 21) {
36
36
  return this.db.prepare('SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?').all(id, limit, offset)
37
- .map(row => ({ ...row, payload: JSON.parse(String(row.payload)) }));
37
+ .map(row => ({ id: String(row.id), issueId: String(row.issueId), version: String(row.version), created: Number(row.created),
38
+ payload: JSON.parse(String(row.payload)) as { title: string; body: string } }));
38
39
  }
39
40
  capture(value: Capture, now = Date.now()) {
40
41
  return this.transaction(() => {
@@ -91,5 +92,16 @@ export class SignalStore {
91
92
  // An interrupted run never counts as fixed.
92
93
  this.db.exec("UPDATE issues SET status='new',lastError='Previous worker interrupted; evidence retained' WHERE status IN ('triaging','repairing'); UPDATE runs SET state='interrupted' WHERE state='running'");
93
94
  }
94
- runs(id: string) { return this.db.prepare('SELECT * FROM runs WHERE issueId=? ORDER BY started').all(id).map(row => ({ ...row, id: String(row.id), state: String(row.state), result: row.result ? JSON.parse(String(row.result)) : undefined })); }
95
+ /** The latest daily triage days, newest first. */
96
+ days(limit = 7) {
97
+ return this.db.prepare('SELECT day,started,finished FROM schedule ORDER BY day DESC LIMIT ?').all(limit)
98
+ .map(row => ({ day: String(row.day), started: Number(row.started), ...(row.finished == null ? {} : { finished: Number(row.finished) }) }));
99
+ }
100
+ runs(id: string) {
101
+ return this.db.prepare('SELECT * FROM runs WHERE issueId=? ORDER BY started').all(id).map(row => ({
102
+ id: String(row.id), issueId: String(row.issueId), revision: Number(row.revision), phase: String(row.phase), owner: String(row.owner),
103
+ state: String(row.state), started: Number(row.started), ...(row.finished == null ? {} : { finished: Number(row.finished) }),
104
+ result: row.result ? JSON.parse(String(row.result)) as unknown : undefined,
105
+ }));
106
+ }
95
107
  }