ei-tui 1.6.8 → 1.7.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.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/package.json +2 -1
  3. package/skills/coding-harness-reflect/SKILL.md +230 -0
  4. package/skills/ei-curate/SKILL.md +174 -0
  5. package/skills/ei-curate/references/cli.md +160 -0
  6. package/skills/ei-curate/references/provenance.md +88 -0
  7. package/skills/ei-curate/references/recipes.md +144 -0
  8. package/skills/ei-curate/references/talking-to-the-user.md +71 -0
  9. package/skills/ei-persona/SKILL.md +238 -0
  10. package/skills/ei-persona/references/cli.md +265 -0
  11. package/skills/ei-persona/references/recipes.md +247 -0
  12. package/skills/ei-persona/references/talking-to-the-user.md +107 -0
  13. package/src/cli/README.md +72 -2
  14. package/src/cli/commands/personas.ts +46 -1
  15. package/src/cli/corrections-endpoints.ts +297 -0
  16. package/src/cli/corrections-writer.ts +138 -0
  17. package/src/cli/install.ts +252 -157
  18. package/src/cli/mcp.ts +80 -1
  19. package/src/cli/persona-corrections.ts +442 -0
  20. package/src/cli/retrieval.ts +46 -2
  21. package/src/cli.ts +148 -1
  22. package/src/core/corrections.ts +233 -0
  23. package/src/core/handlers/human-extraction.ts +8 -2
  24. package/src/core/handlers/human-matching.ts +2 -2
  25. package/src/core/llm-client.ts +7 -1
  26. package/src/core/orchestrators/human-extraction.ts +1 -0
  27. package/src/core/persona-tools.ts +92 -0
  28. package/src/core/personas/opencode-agent.ts +1 -3
  29. package/src/core/processor.ts +113 -1
  30. package/src/core/state/human.ts +10 -0
  31. package/src/core/state/personas.ts +8 -0
  32. package/src/core/state-manager.ts +11 -0
  33. package/src/core/types/entities.ts +1 -0
  34. package/src/core/utils/identifier-utils.ts +3 -2
  35. package/src/integrations/pi/importer.ts +142 -50
  36. package/src/integrations/pi/reader.ts +1 -0
  37. package/src/integrations/pi/types.ts +4 -0
  38. package/src/storage/file-lock.ts +120 -0
  39. package/tui/README.md +2 -0
  40. package/tui/src/commands/provider.tsx +1 -1
  41. package/tui/src/components/WelcomeOverlay.tsx +3 -3
  42. package/tui/src/context/ei.tsx +14 -0
  43. package/tui/src/index.tsx +13 -1
  44. package/tui/src/storage/file.ts +15 -83
  45. package/tui/src/util/instance-lock.ts +3 -2
  46. package/tui/src/util/provider-detection.ts +4 -2
  47. package/tui/src/util/yaml-persona.ts +7 -38
@@ -23,10 +23,14 @@ import {
23
23
  type ToolDefinition,
24
24
  type ToolProvider,
25
25
  } from "./types.js";
26
+ import { isReservedPersonaId } from "./types.js";
26
27
  import { buildPersonaFromPersonPrompt } from "../prompts/index.js";
27
28
  import { buildSiblingAwarenessSection } from "../prompts/room/index.js";
28
29
  import type { PersonaGenerationResult } from "../prompts/generation/types.js";
29
30
 
31
+ import { readCorrections, assertValidCorrection } from "./corrections.js";
32
+ import type { CorrectionRecord } from "./corrections.js";
33
+ import { withLock, atomicWrite } from "../storage/file-lock.js";
30
34
  import type { Storage } from "../storage/interface.js";
31
35
  import { remoteSync } from "../storage/remote.js";
32
36
  import { yoloMerge } from "../storage/merge.js";
@@ -726,7 +730,115 @@ const toolNextSteps = new Set([
726
730
  console.log(`[Processor ${this.instanceId}] runLoop() exited`);
727
731
  }
728
732
 
733
+ /**
734
+ * Drain corrections.json into the live StateManager, every tick (no
735
+ * time-gating — corrections need near-instant pickup, that's the whole
736
+ * point of the external write path). Read + apply + delete all happen
737
+ * inside one withLock() call so a concurrent CLI appendCorrection() can't
738
+ * race the read-drain-delete sequence and lose a correction appended
739
+ * mid-drain.
740
+ *
741
+ * A malformed record (bad entity_type, or a StateManager call that
742
+ * throws) is dropped with a loud console.error rather than aborting the
743
+ * whole batch — corrections.json is external input from CLI/MCP tools
744
+ * potentially driven by an LLM agent, so one bad record must not wedge
745
+ * every subsequent correction behind it forever. This mirrors the
746
+ * existing DLQ/heartbeat error-tolerance philosophy in this file: log
747
+ * loudly, keep the loop alive, keep draining valid records.
748
+ */
749
+ private async drainCorrections(): Promise<void> {
750
+ if (!this.storage) return;
751
+ try {
752
+ const dataPath = this.storage.getDataPath();
753
+ // Web's IndexedDBStorage/LocalStorage return "" — no filesystem, no
754
+ // corrections.json to drain. This mechanism is filesystem-only
755
+ // (TUI/future daemon); Web sessions never write corrections.json in
756
+ // the first place since the CLI/MCP tools that produce it are Node/Bun-only.
757
+ if (!dataPath) return;
758
+ const { join } = await import(/* @vite-ignore */ "path");
759
+ const correctionsPath = join(dataPath, "corrections.json");
760
+
761
+ // Cheap fast path: this runs every 100ms, and readCorrections()
762
+ // short-circuits on a missing file without touching the lock.
763
+ const pending = await readCorrections(correctionsPath);
764
+ if (pending.length === 0) return;
765
+
766
+ await withLock(correctionsPath, async () => {
767
+ // Re-read inside the lock in case a correction was appended between
768
+ // the unlocked fast-path read above and lock acquisition.
769
+ const records = await readCorrections(correctionsPath);
770
+ if (records.length === 0) return;
771
+
772
+ for (const record of records) {
773
+ try {
774
+ this.applyCorrectionRecord(record);
775
+ } catch (err) {
776
+ console.error(`[Processor ${this.instanceId}] Dropping malformed correction record:`, JSON.stringify(record), err);
777
+ }
778
+ }
779
+
780
+ await atomicWrite(correctionsPath, "[]");
781
+ this.interface.onHumanUpdated?.();
782
+ });
783
+ } catch (err) {
784
+ console.error(`[Processor ${this.instanceId}] Failed to drain corrections.json:`, err);
785
+ }
786
+ }
787
+
788
+ /** Dispatch one CorrectionRecord to the matching StateManager upsert/remove call. Throws on an unrecognized entity_type or op (see assertValidCorrection — the same validator the CLI read-merge/self-drain path uses, so a malformed op can never be silently treated as its sibling operation here). */
789
+ private applyCorrectionRecord(record: CorrectionRecord): void {
790
+ assertValidCorrection(record);
791
+ if (record.entity_type === "fact") {
792
+ if (record.op === "upsert") {
793
+ this.stateManager.human_fact_upsert(record.record as Fact);
794
+ } else {
795
+ this.stateManager.human_fact_remove(record.id);
796
+ }
797
+ } else if (record.entity_type === "topic") {
798
+ if (record.op === "upsert") {
799
+ this.stateManager.human_topic_upsert(record.record as Topic);
800
+ } else {
801
+ this.stateManager.human_topic_remove(record.id);
802
+ }
803
+ } else if (record.entity_type === "person") {
804
+ if (record.op === "upsert") {
805
+ this.stateManager.human_person_upsert(record.record as Person);
806
+ } else {
807
+ this.stateManager.human_person_remove(record.id);
808
+ }
809
+ } else if (record.entity_type === "quote") {
810
+ if (record.op === "upsert") {
811
+ this.stateManager.human_quote_upsert(record.record as Quote);
812
+ } else {
813
+ this.stateManager.human_quote_remove(record.id);
814
+ }
815
+ } else if (record.entity_type === "persona") {
816
+ if (record.op === "upsert") {
817
+ const existing = this.stateManager.persona_getById(record.id);
818
+ if (existing) {
819
+ this.stateManager.persona_replace(record.id, record.record as PersonaEntity);
820
+ } else {
821
+ this.stateManager.persona_add(record.record as PersonaEntity);
822
+ }
823
+ } else {
824
+ // Defense in depth — this should never reach here since the
825
+ // synchronous write-time guard in src/cli/persona-corrections.ts's
826
+ // removePersonaEntity already rejects a reserved id before ever
827
+ // queuing the correction. corrections.json is external input,
828
+ // though, and could theoretically be hand-edited to bypass that.
829
+ if (isReservedPersonaId(record.id)) {
830
+ throw new Error(`Cannot delete reserved persona "${record.id}". Use archive instead.`);
831
+ }
832
+ this.stateManager.persona_delete(record.id);
833
+ }
834
+ } else {
835
+ throw new Error(`Unrecognized correction entity_type: ${JSON.stringify(record)}`);
836
+ }
837
+ }
838
+
729
839
  private async checkScheduledTasks(): Promise<void> {
840
+ await this.drainCorrections();
841
+
730
842
  const now = Date.now();
731
843
 
732
844
  const human = this.stateManager.getHuman();
@@ -1291,7 +1403,7 @@ const toolNextSteps = new Set([
1291
1403
  }
1292
1404
 
1293
1405
  async upsertPerson(person: Person): Promise<void> {
1294
- const sanitized = { ...person, identifiers: sanitizeEiPersonaIdentifiers(person.identifiers ?? [], this.stateManager) };
1406
+ const sanitized = { ...person, identifiers: sanitizeEiPersonaIdentifiers(person.identifiers ?? [], this.stateManager.persona_getAll()) };
1295
1407
  await upsertPerson(this.stateManager, sanitized);
1296
1408
  this.interface.onHumanUpdated?.();
1297
1409
  }
@@ -118,6 +118,16 @@ export class HumanState {
118
118
  return false;
119
119
  }
120
120
 
121
+ quote_upsert(quote: Quote): void {
122
+ const idx = this.human.quotes.findIndex((q) => q.id === quote.id);
123
+ if (idx >= 0) {
124
+ this.human.quotes[idx] = quote;
125
+ } else {
126
+ this.human.quotes.push(quote);
127
+ }
128
+ this.human.last_updated = new Date().toISOString();
129
+ }
130
+
121
131
  quote_add(quote: Quote): void {
122
132
  if (!quote.created_at) {
123
133
  quote.created_at = new Date().toISOString();
@@ -80,6 +80,14 @@ export class PersonaState {
80
80
  return true;
81
81
  }
82
82
 
83
+ /** Full replace of a persona's entity (not a merge) — used by the live-drain path for corrections-queue upserts, where record.record is already a complete PersonaEntity and any field genuinely absent from it must NOT survive from the prior value. Preserves .messages untouched, same as update(). */
84
+ replace(personaId: string, entity: PersonaEntity): boolean {
85
+ const data = this.personas.get(personaId);
86
+ if (!data) return false;
87
+ data.entity = { ...entity, last_updated: new Date().toISOString() };
88
+ return true;
89
+ }
90
+
83
91
  archive(personaId: string): boolean {
84
92
  const data = this.personas.get(personaId);
85
93
  if (!data) return false;
@@ -715,6 +715,11 @@ export class StateManager {
715
715
  this.scheduleSave();
716
716
  }
717
717
 
718
+ human_quote_upsert(quote: Quote): void {
719
+ this.humanState.quote_upsert(quote);
720
+ this.scheduleSave();
721
+ }
722
+
718
723
  human_quote_update(id: string, updates: Partial<Quote>): boolean {
719
724
  const result = this.humanState.quote_update(id, updates);
720
725
  this.scheduleSave();
@@ -758,6 +763,12 @@ export class StateManager {
758
763
  return result;
759
764
  }
760
765
 
766
+ persona_replace(personaId: string, entity: PersonaEntity): boolean {
767
+ const result = this.personaState.replace(personaId, entity);
768
+ this.scheduleSave();
769
+ return result;
770
+ }
771
+
761
772
  persona_archive(personaId: string): boolean {
762
773
  const result = this.personaState.archive(personaId);
763
774
  this.scheduleSave();
@@ -60,6 +60,7 @@ export interface ModelConfig {
60
60
  token_limit?: number; // Input token limit (user sets effective limit)
61
61
  max_output_tokens?: number; // Output token limit (API-enforced)
62
62
  thinking_budget?: number; // Thinking token budget: 0 = disabled, N = enable with N tokens, undefined = don't send
63
+ temperature_disabled?: boolean; // Set true for models that reject temperature (e.g. Anthropic extended-thinking models)
63
64
  total_calls?: number; // Usage counter
64
65
  total_tokens_in?: number; // Usage counter
65
66
  total_tokens_out?: number; // Usage counter
@@ -1,4 +1,5 @@
1
1
  import type { PersonIdentifier } from "../types/data-items.js";
2
+ import type { PersonaEntity } from "../types/entities.js";
2
3
  import type { StateManager } from "../state-manager.js";
3
4
  import { BUILT_IN_IDENTIFIER_TYPES } from "../constants/built-in-identifier-types.js";
4
5
 
@@ -29,12 +30,12 @@ export function normalizeIdentifierType(llmType: string, state: StateManager): s
29
30
 
30
31
  export function sanitizeEiPersonaIdentifiers(
31
32
  identifiers: PersonIdentifier[],
32
- state: StateManager
33
+ personas: PersonaEntity[]
33
34
  ): PersonIdentifier[] {
34
35
  return identifiers.map(id => {
35
36
  if (id.type !== 'Ei Persona' && id.type !== 'AI Persona') return id;
36
37
  if (UUID_REGEX.test(id.value)) return { ...id, type: 'Ei Persona' };
37
- const matched = state.persona_getAll().find(p =>
38
+ const matched = personas.find(p =>
38
39
  p.display_name === id.value || p.aliases?.includes(id.value)
39
40
  );
40
41
  if (matched) return { ...id, type: 'Ei Persona', value: matched.id };
@@ -20,11 +20,16 @@ import {
20
20
  type IPiReader,
21
21
  } from "./types.js";
22
22
  import { MIN_SESSION_AGE_MS, TWELVE_HOURS_MS } from "../constants.js";
23
+ import {
24
+ ensureAgentPersona,
25
+ resolveCanonicalAgent,
26
+ } from "../../core/personas/opencode-agent.js";
23
27
 
24
28
  export interface PiImportResult {
25
29
  sessionsProcessed: number;
26
30
  messagesImported: number;
27
31
  personaCreated: boolean;
32
+ personasCreated: string[];
28
33
  extractionScansQueued: number;
29
34
  }
30
35
 
@@ -121,6 +126,7 @@ export async function importPiSessions(options: PiImporterOptions): Promise<PiIm
121
126
  sessionsProcessed: 0,
122
127
  messagesImported: 0,
123
128
  personaCreated: false,
129
+ personasCreated: [],
124
130
  extractionScansQueued: 0,
125
131
  };
126
132
 
@@ -167,56 +173,142 @@ export async function importPiSessions(options: PiImporterOptions): Promise<PiIm
167
173
 
168
174
  if (signal?.aborted) return result;
169
175
 
170
- const personaExistedBefore = stateManager.persona_getByName(PI_PERSONA_NAME) !== null;
171
- const persona = ensurePiPersona(stateManager, eiInterface);
172
- result.personaCreated = !personaExistedBefore;
173
-
174
- const existingMsgs = stateManager.messages_get(persona.id);
175
- const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
176
- if (externalIds.length > 0) {
177
- stateManager.messages_remove(persona.id, externalIds);
178
- }
179
-
180
- const cutoffIso = processedSessions[targetSession.id] ?? null;
181
- const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
182
- const toAnalyze: Message[] = [];
183
-
184
- for (const msg of messages) {
185
- const msgMs = new Date(msg.timestamp).getTime();
186
- const isOld = cutoffMs !== null && msgMs < cutoffMs;
187
- const eiMsg = isOld
188
- ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
189
- : convertToEiMessage(msg, targetSession.id, qualify);
190
-
191
- stateManager.messages_append(persona.id, eiMsg);
192
- result.messagesImported++;
193
- if (!isOld) toAnalyze.push(eiMsg);
194
- }
195
-
196
- stateManager.messages_sort(persona.id);
197
- eiInterface?.onMessageAdded?.(persona.id);
198
-
199
- if (toAnalyze.length > 0 && !signal?.aborted) {
200
- const allInState = stateManager.messages_get(persona.id);
201
- const analyzeIds = new Set(toAnalyze.map((m) => m.id));
202
- const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
203
- const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
204
-
205
- const context: ExtractionContext = {
206
- personaId: persona.id,
207
- channelDisplayName: persona.display_name,
208
- messages_context: contextMsgs,
209
- messages_analyze: toAnalyze,
210
- sources: [`pi:${getMachineId()}:${targetSession.id}`],
211
- };
212
-
213
- queuePersonRewritePhase(stateManager);
214
- queueTopicRewritePhase(stateManager);
215
- queueAllScans(context, stateManager, {
216
- extraction_model: human.settings?.pi?.extraction_model,
217
- external_filter: "only",
218
- });
219
- result.extractionScansQueued += 4;
176
+ const hasAgentAttribution = messages.some((m) => m.agent != null);
177
+
178
+ if (!hasAgentAttribution) {
179
+ // ─── Single-persona path (vanilla Pi / OMP without active agent) ────────
180
+ const personaExistedBefore = stateManager.persona_getByName(PI_PERSONA_NAME) !== null;
181
+ const persona = ensurePiPersona(stateManager, eiInterface);
182
+ result.personaCreated = !personaExistedBefore;
183
+
184
+ const existingMsgs = stateManager.messages_get(persona.id);
185
+ const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
186
+ if (externalIds.length > 0) {
187
+ stateManager.messages_remove(persona.id, externalIds);
188
+ }
189
+
190
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
191
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
192
+ const toAnalyze: Message[] = [];
193
+
194
+ for (const msg of messages) {
195
+ const msgMs = new Date(msg.timestamp).getTime();
196
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
197
+ const eiMsg = isOld
198
+ ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
199
+ : convertToEiMessage(msg, targetSession.id, qualify);
200
+
201
+ stateManager.messages_append(persona.id, eiMsg);
202
+ result.messagesImported++;
203
+ if (!isOld) toAnalyze.push(eiMsg);
204
+ }
205
+
206
+ stateManager.messages_sort(persona.id);
207
+ eiInterface?.onMessageAdded?.(persona.id);
208
+
209
+ if (toAnalyze.length > 0 && !signal?.aborted) {
210
+ const allInState = stateManager.messages_get(persona.id);
211
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
212
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
213
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
214
+
215
+ const context: ExtractionContext = {
216
+ personaId: persona.id,
217
+ channelDisplayName: persona.display_name,
218
+ messages_context: contextMsgs,
219
+ messages_analyze: toAnalyze,
220
+ sources: [`pi:${getMachineId()}:${targetSession.id}`],
221
+ };
222
+
223
+ queuePersonRewritePhase(stateManager);
224
+ queueTopicRewritePhase(stateManager);
225
+ queueAllScans(context, stateManager, {
226
+ extraction_model: human.settings?.pi?.extraction_model,
227
+ external_filter: "only",
228
+ });
229
+ result.extractionScansQueued += 4;
230
+ }
231
+ } else {
232
+ // ─── Multi-agent path (OMP sessions with agent attribution) ─────────────
233
+ const byPersonaId = new Map<string, { persona: NonNullable<ReturnType<typeof stateManager.persona_getByName>>; msgs: typeof messages; agentName: string }>();
234
+
235
+ for (const msg of messages) {
236
+ const agentName = msg.agent ?? PI_PERSONA_NAME;
237
+ let persona = stateManager.persona_getByName(agentName);
238
+ if (!persona) {
239
+ const { canonical } = resolveCanonicalAgent(agentName);
240
+ persona = stateManager.persona_getByName(canonical);
241
+ }
242
+ if (!persona) {
243
+ persona = await ensureAgentPersona(agentName, {
244
+ stateManager,
245
+ interface: eiInterface,
246
+ reader: undefined,
247
+ });
248
+ result.personasCreated.push(agentName);
249
+ }
250
+ const bucket = byPersonaId.get(persona.id);
251
+ if (bucket) {
252
+ bucket.msgs.push(msg);
253
+ } else {
254
+ byPersonaId.set(persona.id, { persona, msgs: [msg], agentName });
255
+ }
256
+ }
257
+
258
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
259
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
260
+ let anyPersonaHasChanges = false;
261
+
262
+ for (const [, { persona, msgs: agentMsgs }] of byPersonaId) {
263
+ const existingMsgs = stateManager.messages_get(persona.id);
264
+ const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
265
+ if (externalIds.length > 0) {
266
+ stateManager.messages_remove(persona.id, externalIds);
267
+ }
268
+
269
+ const toAnalyze: Message[] = [];
270
+ for (const msg of agentMsgs) {
271
+ const msgMs = new Date(msg.timestamp).getTime();
272
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
273
+ const eiMsg = isOld
274
+ ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
275
+ : convertToEiMessage(msg, targetSession.id, qualify);
276
+
277
+ stateManager.messages_append(persona.id, eiMsg);
278
+ result.messagesImported++;
279
+ if (!isOld) toAnalyze.push(eiMsg);
280
+ }
281
+
282
+ stateManager.messages_sort(persona.id);
283
+ eiInterface?.onMessageAdded?.(persona.id);
284
+
285
+ if (toAnalyze.length > 0 && !signal?.aborted) {
286
+ const allInState = stateManager.messages_get(persona.id);
287
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
288
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
289
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
290
+
291
+ const context: ExtractionContext = {
292
+ personaId: persona.id,
293
+ channelDisplayName: persona.display_name,
294
+ messages_context: contextMsgs,
295
+ messages_analyze: toAnalyze,
296
+ sources: [`pi:${getMachineId()}:${targetSession.id}`],
297
+ };
298
+
299
+ anyPersonaHasChanges = true;
300
+ queueAllScans(context, stateManager, {
301
+ extraction_model: human.settings?.pi?.extraction_model,
302
+ external_filter: "only",
303
+ });
304
+ result.extractionScansQueued += 4;
305
+ }
306
+ }
307
+
308
+ if (anyPersonaHasChanges && !signal?.aborted) {
309
+ queuePersonRewritePhase(stateManager);
310
+ queueTopicRewritePhase(stateManager);
311
+ }
220
312
  }
221
313
 
222
314
  result.sessionsProcessed = 1;
@@ -199,6 +199,7 @@ export class PiReader implements IPiReader {
199
199
  role,
200
200
  content,
201
201
  timestamp: ts ?? new Date(0).toISOString(),
202
+ agent: entry.agent as string | undefined,
202
203
  });
203
204
  }
204
205
 
@@ -49,6 +49,8 @@ export interface PiMessageEntry {
49
49
  id: string;
50
50
  parentId?: string;
51
51
  timestamp: string;
52
+ /** Active agent definition name, present only for OMP sessions. Mirrors the `agent` field on OpenCode message rows. */
53
+ agent?: string;
52
54
  message: PiMessagePayload;
53
55
  [key: string]: unknown;
54
56
  }
@@ -112,6 +114,8 @@ export interface PiMessage {
112
114
  role: "user" | "assistant";
113
115
  content: string;
114
116
  timestamp: string;
117
+ /** Active agent definition name when this message was recorded; undefined for vanilla Pi or OMP without an active agent. */
118
+ agent?: string;
115
119
  }
116
120
 
117
121
  // ============================================================================
@@ -0,0 +1,120 @@
1
+ /**
2
+ * File-based advisory locking + atomic writes, shared by any Storage
3
+ * implementation (and CLI tooling) that needs to serialize writes to a
4
+ * JSON file on disk without a database.
5
+ *
6
+ * Extracted from tui/src/storage/file.ts so the CLI's corrections.json
7
+ * writer/drainer can use identical lock semantics instead of forking a
8
+ * second implementation.
9
+ *
10
+ * fs/promises is imported dynamically per-function, not statically at
11
+ * module scope: this module is transitively imported by src/core/corrections.ts
12
+ * -> src/core/processor.ts, which Web's Vite build bundles for the browser.
13
+ * A static `import { readFile } from "fs/promises"` makes rollup try to
14
+ * resolve named exports against Vite's browser-externalized stub, which
15
+ * has none — that's a hard build failure, not a runtime one (same pattern
16
+ * already used in src/cli.ts and src/cli/install.ts for this exact reason).
17
+ */
18
+
19
+ const LOCK_TIMEOUT_MS = 5000;
20
+ const LOCK_RETRY_DELAY_MS = 50;
21
+
22
+ // Plain executor form, not Promise.withResolvers — this module runs on
23
+ // whatever Node the CLI/TUI ships with, and withResolvers only landed in
24
+ // Node 22. No behavior difference, just broader runtime compatibility.
25
+ function delay(ms: number): Promise<void> {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+
29
+ export function getLockPath(filePath: string): string {
30
+ return `${filePath}.lock`;
31
+ }
32
+
33
+ /**
34
+ * Acquire an advisory lock on filePath. Stale locks (older than
35
+ * LOCK_TIMEOUT_MS) are broken automatically. Returns false if the lock
36
+ * could not be acquired within LOCK_TIMEOUT_MS.
37
+ */
38
+ export async function acquireLock(filePath: string): Promise<boolean> {
39
+ const { readFile, writeFile, unlink } = await import(/* @vite-ignore */ "fs/promises");
40
+ const lockPath = getLockPath(filePath);
41
+ const startTime = Date.now();
42
+
43
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
44
+ // readFile throws ENOENT when the lock is absent — treated the same as
45
+ // "lock vanished mid-read" below: proceed straight to acquire.
46
+ let lockContent: string | null = null;
47
+ try {
48
+ lockContent = await readFile(lockPath, "utf-8");
49
+ } catch {
50
+ lockContent = null;
51
+ }
52
+
53
+ if (lockContent !== null) {
54
+ const lockTime = parseInt(lockContent, 10);
55
+ if (!isNaN(lockTime) && Date.now() - lockTime > LOCK_TIMEOUT_MS) {
56
+ try {
57
+ await unlink(lockPath);
58
+ } catch {}
59
+ } else {
60
+ await delay(LOCK_RETRY_DELAY_MS);
61
+ continue;
62
+ }
63
+ }
64
+
65
+ try {
66
+ // "wx" = exclusive create, fails with EEXIST if the path already
67
+ // exists. Without this flag, two callers that both observed the lock
68
+ // as absent (the check above) could both reach this writeFile and
69
+ // both succeed — writeFile has no atomicity of its own, it just
70
+ // overwrites. "wx" makes the write itself the atomic test-and-set:
71
+ // only one caller can win it, the other gets EEXIST and falls into
72
+ // the catch below to retry from the top of the loop.
73
+ await writeFile(lockPath, Date.now().toString(), { flag: "wx" });
74
+ return true;
75
+ } catch {
76
+ await delay(LOCK_RETRY_DELAY_MS);
77
+ }
78
+ }
79
+
80
+ return false;
81
+ }
82
+
83
+ export async function releaseLock(filePath: string): Promise<void> {
84
+ const { unlink } = await import(/* @vite-ignore */ "fs/promises");
85
+ const lockPath = getLockPath(filePath);
86
+ try {
87
+ await unlink(lockPath);
88
+ } catch {}
89
+ }
90
+
91
+ /**
92
+ * Run fn() while holding filePath's advisory lock. Throws
93
+ * STORAGE_LOCK_TIMEOUT if the lock can't be acquired in time.
94
+ */
95
+ export async function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
96
+ const acquired = await acquireLock(filePath);
97
+ if (!acquired) {
98
+ throw new Error("STORAGE_LOCK_TIMEOUT: Could not acquire file lock");
99
+ }
100
+ try {
101
+ return await fn();
102
+ } finally {
103
+ await releaseLock(filePath);
104
+ }
105
+ }
106
+
107
+ /** Write content to filePath via temp-file + rename, so readers never see a partial write. */
108
+ export async function atomicWrite(filePath: string, content: string): Promise<void> {
109
+ const { writeFile, rename, unlink } = await import(/* @vite-ignore */ "fs/promises");
110
+ const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
111
+ try {
112
+ await writeFile(tempPath, content);
113
+ await rename(tempPath, filePath);
114
+ } catch (e) {
115
+ try {
116
+ await unlink(tempPath);
117
+ } catch {}
118
+ throw e;
119
+ }
120
+ }
package/tui/README.md CHANGED
@@ -174,6 +174,8 @@ Rooms have three modes, set at creation time:
174
174
  | `/tools` | | Manage tool providers — enable/disable tools per persona |
175
175
  | `/auth <service>` | | Authenticate with an external service via OAuth. Supported: `spotify`, `slack` |
176
176
 
177
+ > Disabling a tool provider hides its tools from `/details`' YAML editor, but doesn't revoke them — a persona's grants for a disabled provider's tools survive an unrelated `/details` edit and reappear once you re-enable the provider with `/tools` or `/provider`.
178
+
177
179
  ### Editor
178
180
 
179
181
  | Command | Aliases | Description |
@@ -48,7 +48,7 @@ export async function openModelOverlay(ctx: Parameters<Command["execute"]>[1]):
48
48
  const models = await buildModelList(ctx);
49
49
 
50
50
  if (models.length === 0) {
51
- ctx.showNotification("No models configured. Use /provider new to create one.", "info");
51
+ await createProviderViaEditor(ctx);
52
52
  return;
53
53
  }
54
54
 
@@ -90,9 +90,9 @@ export function WelcomeOverlay(props: WelcomeOverlayProps) {
90
90
  <box visible={!hasAny()} flexDirection="column">
91
91
  <text fg="#dc322f">No LLM provider detected.</text>
92
92
  <text> </text>
93
- <text fg="#93a1a1">Start LMStudio (port 1234) or Ollama (port 11434), or</text>
94
- <text fg="#93a1a1">set one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY,</text>
95
- <text fg="#93a1a1">MISTRAL_API_KEY, GEMINI_API_KEY and restart.</text>
93
+ <text fg="#93a1a1">Use /provider new to configure one manually, or</text>
94
+ <text fg="#93a1a1">start LMStudio (port 1234) / Ollama (port 11434), or</text>
95
+ <text fg="#93a1a1">set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc. and restart.</text>
96
96
  </box>
97
97
 
98
98
  <text> </text>