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.
- package/README.md +4 -0
- package/package.json +2 -1
- package/skills/coding-harness-reflect/SKILL.md +230 -0
- package/skills/ei-curate/SKILL.md +174 -0
- package/skills/ei-curate/references/cli.md +160 -0
- package/skills/ei-curate/references/provenance.md +88 -0
- package/skills/ei-curate/references/recipes.md +144 -0
- package/skills/ei-curate/references/talking-to-the-user.md +71 -0
- package/skills/ei-persona/SKILL.md +238 -0
- package/skills/ei-persona/references/cli.md +265 -0
- package/skills/ei-persona/references/recipes.md +247 -0
- package/skills/ei-persona/references/talking-to-the-user.md +107 -0
- package/src/cli/README.md +72 -2
- package/src/cli/commands/personas.ts +46 -1
- package/src/cli/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +252 -157
- package/src/cli/mcp.ts +80 -1
- package/src/cli/persona-corrections.ts +442 -0
- package/src/cli/retrieval.ts +46 -2
- package/src/cli.ts +148 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-extraction.ts +8 -2
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/llm-client.ts +7 -1
- package/src/core/orchestrators/human-extraction.ts +1 -0
- package/src/core/persona-tools.ts +92 -0
- package/src/core/personas/opencode-agent.ts +1 -3
- package/src/core/processor.ts +113 -1
- package/src/core/state/human.ts +10 -0
- package/src/core/state/personas.ts +8 -0
- package/src/core/state-manager.ts +11 -0
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/identifier-utils.ts +3 -2
- package/src/integrations/pi/importer.ts +142 -50
- package/src/integrations/pi/reader.ts +1 -0
- package/src/integrations/pi/types.ts +4 -0
- package/src/storage/file-lock.ts +120 -0
- package/tui/README.md +2 -0
- package/tui/src/commands/provider.tsx +1 -1
- package/tui/src/components/WelcomeOverlay.tsx +3 -3
- package/tui/src/context/ei.tsx +14 -0
- package/tui/src/index.tsx +13 -1
- package/tui/src/storage/file.ts +15 -83
- package/tui/src/util/instance-lock.ts +3 -2
- package/tui/src/util/provider-detection.ts +4 -2
- package/tui/src/util/yaml-persona.ts +7 -38
package/src/core/processor.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/state/human.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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;
|
|
@@ -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
|
|
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">
|
|
94
|
-
<text fg="#93a1a1">
|
|
95
|
-
<text fg="#93a1a1">
|
|
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>
|