niahere 0.5.0 → 0.5.1
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/package.json +1 -1
- package/src/agent/backends/claude.ts +2 -1
- package/src/agent/backends/codex.ts +2 -1
- package/src/agent/mcp-endpoint.ts +3 -2
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +17 -8
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/core/alive.ts +4 -3
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +7 -6
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +12 -0
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- package/src/utils/errors.ts +26 -0
|
@@ -73,16 +73,6 @@ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
|
|
|
73
73
|
return { callSid: data.sid, status: data.status };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
|
|
77
|
-
const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
|
|
78
|
-
await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
|
|
82
|
-
const body = new URLSearchParams({ Status: "completed" });
|
|
83
|
-
await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
// --- Messages (SMS / WhatsApp) ---
|
|
87
77
|
|
|
88
78
|
export interface SendMessageOpts extends TwilioCreds {
|
|
@@ -111,24 +101,3 @@ export async function sendMessage(opts: SendMessageOpts): Promise<SendMessageRes
|
|
|
111
101
|
}
|
|
112
102
|
|
|
113
103
|
// --- Phone number config (update inbound webhook on a number) ---
|
|
114
|
-
|
|
115
|
-
export async function updateIncomingPhoneNumber(
|
|
116
|
-
opts: TwilioCreds & {
|
|
117
|
-
phoneNumberSid: string;
|
|
118
|
-
voiceUrl?: string;
|
|
119
|
-
voiceMethod?: "GET" | "POST";
|
|
120
|
-
smsUrl?: string;
|
|
121
|
-
smsMethod?: "GET" | "POST";
|
|
122
|
-
statusCallback?: string;
|
|
123
|
-
statusCallbackMethod?: "GET" | "POST";
|
|
124
|
-
},
|
|
125
|
-
): Promise<void> {
|
|
126
|
-
const body = new URLSearchParams();
|
|
127
|
-
if (opts.voiceUrl !== undefined) body.set("VoiceUrl", opts.voiceUrl);
|
|
128
|
-
if (opts.voiceMethod) body.set("VoiceMethod", opts.voiceMethod);
|
|
129
|
-
if (opts.smsUrl !== undefined) body.set("SmsUrl", opts.smsUrl);
|
|
130
|
-
if (opts.smsMethod) body.set("SmsMethod", opts.smsMethod);
|
|
131
|
-
if (opts.statusCallback !== undefined) body.set("StatusCallback", opts.statusCallback);
|
|
132
|
-
if (opts.statusCallbackMethod) body.set("StatusCallbackMethod", opts.statusCallbackMethod);
|
|
133
|
-
await twilioPost(opts, `/IncomingPhoneNumbers/${encodeURIComponent(opts.phoneNumberSid)}.json`, body);
|
|
134
|
-
}
|
|
@@ -248,8 +248,3 @@ export function getTwilioServer(): TwilioServer {
|
|
|
248
248
|
if (!_instance) _instance = new TwilioServerImpl();
|
|
249
249
|
return _instance;
|
|
250
250
|
}
|
|
251
|
-
|
|
252
|
-
/** Test-only: drop the singleton so tests get a fresh server. */
|
|
253
|
-
export function resetTwilioServer(): void {
|
|
254
|
-
_instance = null;
|
|
255
|
-
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TwilioConfig } from "../../types";
|
|
2
|
+
import { log } from "../../utils/log";
|
|
3
|
+
|
|
4
|
+
/** Bits every Twilio-backed channel (sms, whatsapp) needs identically. */
|
|
5
|
+
|
|
6
|
+
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
7
|
+
|
|
8
|
+
/** Ack an inbound webhook immediately. Replies go out over REST instead, so a
|
|
9
|
+
* slow engine cannot blow Twilio's ~15s webhook budget. */
|
|
10
|
+
export function ackTwiml(): Response {
|
|
11
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAllowedSender(twilio: TwilioConfig, remoteE164: string): boolean {
|
|
15
|
+
if (twilio.owner_number && remoteE164 === twilio.owner_number) return true;
|
|
16
|
+
return twilio.allowlist.includes(remoteE164);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type StatusLogger = (label: string, fields: Record<string, unknown>) => void;
|
|
20
|
+
|
|
21
|
+
const logStatus: StatusLogger = (label, fields) => log.info(fields, `${label}: delivery status`);
|
|
22
|
+
|
|
23
|
+
/** Record a delivery-status callback and ack it. */
|
|
24
|
+
export function deliveryStatusAck(
|
|
25
|
+
label: string,
|
|
26
|
+
params: Record<string, string>,
|
|
27
|
+
record: StatusLogger = logStatus,
|
|
28
|
+
): Response {
|
|
29
|
+
record(label, {
|
|
30
|
+
messageSid: params.MessageSid,
|
|
31
|
+
status: params.MessageStatus,
|
|
32
|
+
errorCode: params.ErrorCode,
|
|
33
|
+
to: params.To,
|
|
34
|
+
});
|
|
35
|
+
return new Response("", { status: 204 });
|
|
36
|
+
}
|
package/src/channels/whatsapp.ts
CHANGED
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
import { getMcpServers } from "../mcp";
|
|
21
21
|
import { Message } from "../db/models";
|
|
22
22
|
import { runMigrations } from "../db/migrate";
|
|
23
|
-
import {
|
|
23
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
24
|
+
import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
|
|
24
25
|
import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
|
|
25
26
|
import { getConfig } from "../utils/config";
|
|
26
27
|
import { log } from "../utils/log";
|
|
28
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
27
29
|
import { classifyMime, prepareImage, validateAttachment } from "../utils/attachment";
|
|
28
30
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
29
31
|
import { getTwilioServer } from "./twilio/server";
|
|
@@ -32,7 +34,6 @@ import { transcribeAudio } from "./twilio/transcribe";
|
|
|
32
34
|
|
|
33
35
|
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
34
36
|
const WA_PREFIX = "whatsapp:";
|
|
35
|
-
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
36
37
|
const CHUNK_LIMIT = 4096;
|
|
37
38
|
const RESET_RE = /^\s*\/(reset|new)\s*$/i;
|
|
38
39
|
const VOICE_MIME_PREFIX = "audio/";
|
|
@@ -42,13 +43,14 @@ class WhatsAppChannel implements Channel {
|
|
|
42
43
|
private readonly twilio: TwilioConfig;
|
|
43
44
|
private readonly whatsapp: WhatsappConfig;
|
|
44
45
|
private readonly phone: PhoneConfig;
|
|
45
|
-
private readonly chats
|
|
46
|
+
private readonly chats: ChatSessions<string>;
|
|
46
47
|
private readonly lastInboundAt = new Map<string, number>();
|
|
47
48
|
|
|
48
49
|
constructor(twilio: TwilioConfig, whatsapp: WhatsappConfig, phone: PhoneConfig) {
|
|
49
50
|
this.twilio = twilio;
|
|
50
51
|
this.whatsapp = whatsapp;
|
|
51
52
|
this.phone = phone;
|
|
53
|
+
this.chats = new ChatSessions(roomPrefix, () => ({ channel: "whatsapp", mcpServers: getMcpServers() }));
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async start(): Promise<void> {
|
|
@@ -86,8 +88,7 @@ class WhatsAppChannel implements Channel {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
async stop(): Promise<void> {
|
|
89
|
-
|
|
90
|
-
this.chats.clear();
|
|
91
|
+
this.chats.closeAll();
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Outbound to the owner — used by send_message MCP tool. WhatsApp has no threading. */
|
|
@@ -108,9 +109,9 @@ class WhatsAppChannel implements Channel {
|
|
|
108
109
|
const from = (params.From || "").replace(/^whatsapp:/, "");
|
|
109
110
|
const body = (params.Body || "").trim();
|
|
110
111
|
|
|
111
|
-
if (!this.
|
|
112
|
+
if (!isAllowedSender(this.twilio, from)) {
|
|
112
113
|
log.warn({ from }, "whatsapp: rejecting non-allowlisted sender");
|
|
113
|
-
return
|
|
114
|
+
return ackTwiml();
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
this.lastInboundAt.set(from, Date.now());
|
|
@@ -118,20 +119,20 @@ class WhatsAppChannel implements Channel {
|
|
|
118
119
|
if (RESET_RE.test(body)) {
|
|
119
120
|
// Serialize through the same lock so a /reset chasing an in-flight
|
|
120
121
|
// engine.send() waits its turn instead of yanking the engine away.
|
|
121
|
-
const state = await this.
|
|
122
|
+
const state = await this.chats.get(from);
|
|
122
123
|
chainLock(state, async () => {
|
|
123
|
-
const newState = await this.
|
|
124
|
+
const newState = await this.chats.rotate(from);
|
|
124
125
|
await this.sendTextTo(
|
|
125
126
|
from,
|
|
126
|
-
`New conversation started (room ${
|
|
127
|
+
`New conversation started (room ${roomPrefix(from)}-${newState.roomIndex}).`,
|
|
127
128
|
).catch((err) => log.warn({ err, from }, "whatsapp: failed to send reset confirmation"));
|
|
128
129
|
});
|
|
129
|
-
return
|
|
130
|
+
return ackTwiml();
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
const descriptors = extractMedia(params);
|
|
133
134
|
|
|
134
|
-
const state = await this.
|
|
135
|
+
const state = await this.chats.get(from);
|
|
135
136
|
chainLock(state, async () => {
|
|
136
137
|
let userText = body;
|
|
137
138
|
let attachments: Attachment[] | undefined;
|
|
@@ -162,7 +163,7 @@ class WhatsAppChannel implements Channel {
|
|
|
162
163
|
} catch (err) {
|
|
163
164
|
log.error({ err, from }, "whatsapp: voice transcription failed");
|
|
164
165
|
voiceParts.push(
|
|
165
|
-
`[voice note: transcription failed — ${
|
|
166
|
+
`[voice note: transcription failed — ${errMsg(err)}]`,
|
|
166
167
|
);
|
|
167
168
|
}
|
|
168
169
|
continue;
|
|
@@ -171,7 +172,7 @@ class WhatsAppChannel implements Channel {
|
|
|
171
172
|
const error = validateAttachment(item.data);
|
|
172
173
|
if (error) {
|
|
173
174
|
log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
|
|
174
|
-
await this.sendTextTo(from, `[error] ${error}`)
|
|
175
|
+
await ignore(this.sendTextTo(from, `[error] ${error}`), "reply engine error");
|
|
175
176
|
continue;
|
|
176
177
|
}
|
|
177
178
|
|
|
@@ -203,32 +204,23 @@ class WhatsAppChannel implements Channel {
|
|
|
203
204
|
const reply = result.trim() || "(no response)";
|
|
204
205
|
try {
|
|
205
206
|
await this.sendTextTo(from, reply);
|
|
206
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
207
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
207
208
|
} catch (sendErr) {
|
|
208
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed")
|
|
209
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
|
|
209
210
|
throw sendErr;
|
|
210
211
|
}
|
|
211
212
|
} catch (err) {
|
|
212
213
|
log.error({ err, from }, "whatsapp: engine error");
|
|
213
|
-
const errText =
|
|
214
|
-
await this.sendTextTo(from, `[error] ${errText}`)
|
|
214
|
+
const errText = errMsg(err);
|
|
215
|
+
await ignore(this.sendTextTo(from, `[error] ${errText}`), "reply engine error");
|
|
215
216
|
}
|
|
216
217
|
});
|
|
217
218
|
|
|
218
|
-
return
|
|
219
|
+
return ackTwiml();
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
private handleStatus(params: Record<string, string>): Response {
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
messageSid: params.MessageSid,
|
|
225
|
-
status: params.MessageStatus,
|
|
226
|
-
errorCode: params.ErrorCode,
|
|
227
|
-
to: params.To,
|
|
228
|
-
},
|
|
229
|
-
"whatsapp: delivery status",
|
|
230
|
-
);
|
|
231
|
-
return new Response("", { status: 204 });
|
|
223
|
+
return deliveryStatusAck("whatsapp", params);
|
|
232
224
|
}
|
|
233
225
|
|
|
234
226
|
// --- Outbound ---
|
|
@@ -301,36 +293,10 @@ class WhatsAppChannel implements Channel {
|
|
|
301
293
|
return true;
|
|
302
294
|
}
|
|
303
295
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
private isAllowed(remoteE164: string): boolean {
|
|
307
|
-
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
308
|
-
return this.twilio.allowlist.includes(remoteE164);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private roomPrefix(remoteE164: string): string {
|
|
312
|
-
return `wa-${remoteE164}`;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private async getState(remoteE164: string): Promise<ChatState> {
|
|
316
|
-
let state = this.chats.get(remoteE164);
|
|
317
|
-
if (state) return state;
|
|
318
|
-
state = await openChatEngine(this.roomPrefix(remoteE164), () => ({
|
|
319
|
-
channel: "whatsapp",
|
|
320
|
-
mcpServers: getMcpServers(),
|
|
321
|
-
}));
|
|
322
|
-
this.chats.set(remoteE164, state);
|
|
323
|
-
return state;
|
|
324
|
-
}
|
|
296
|
+
}
|
|
325
297
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
channel: "whatsapp",
|
|
329
|
-
mcpServers: getMcpServers(),
|
|
330
|
-
}));
|
|
331
|
-
this.chats.set(remoteE164, state);
|
|
332
|
-
return state;
|
|
333
|
-
}
|
|
298
|
+
function roomPrefix(remoteE164: string): string {
|
|
299
|
+
return `wa-${remoteE164}`;
|
|
334
300
|
}
|
|
335
301
|
|
|
336
302
|
/**
|
package/src/chat/engine.ts
CHANGED
|
@@ -4,10 +4,13 @@ import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./iden
|
|
|
4
4
|
import { buildEmployeePrompt } from "./employee-prompt";
|
|
5
5
|
import { getEmployee } from "../core/employees";
|
|
6
6
|
import { getAgentDefinitions, scanAgents } from "../core/agents";
|
|
7
|
+
import { gapMarker } from "./gap-marker";
|
|
8
|
+
import { getConfig } from "../utils/config";
|
|
7
9
|
import { Session, Message, ActiveEngine, Job } from "../db/models";
|
|
8
10
|
import type { Attachment, SendResult, SendCallbacks, ChatEngine, EngineOptions } from "../types";
|
|
9
11
|
import { finalizeSession, cancelPending } from "../core/finalizer";
|
|
10
12
|
import { log } from "../utils/log";
|
|
13
|
+
import { asError, errMsg, ignore } from "../utils/errors";
|
|
11
14
|
import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
|
|
12
15
|
import { resolveJobPrompt } from "../core/job-prompt";
|
|
13
16
|
import { truncate } from "../utils/format-activity";
|
|
@@ -169,7 +172,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
169
172
|
clearIdleTimer();
|
|
170
173
|
clearLongRunningTimer();
|
|
171
174
|
if (session) {
|
|
172
|
-
await session.close()
|
|
175
|
+
await ignore(session.close(), "close chat session");
|
|
173
176
|
session = null;
|
|
174
177
|
}
|
|
175
178
|
unregisterActiveHandle(room);
|
|
@@ -218,7 +221,13 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
218
221
|
return room;
|
|
219
222
|
},
|
|
220
223
|
|
|
221
|
-
async send(
|
|
224
|
+
async send(rawMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
|
|
225
|
+
// Date the turn when time has visibly passed. Computed before anything is
|
|
226
|
+
// saved, and used for BOTH the send and the save so the stored record is
|
|
227
|
+
// exactly what the model received.
|
|
228
|
+
const marker = gapMarker(new Date(), await Message.getLastAt(room).catch(() => null), getConfig().timezone);
|
|
229
|
+
const userMessage = marker ? `${marker}\n${rawMessage}` : rawMessage;
|
|
230
|
+
|
|
222
231
|
// Re-probe from the top once the failed provider's cooldown lapses, so a
|
|
223
232
|
// brief outage does not pin the conversation to the fallback for good.
|
|
224
233
|
if (!cursor.atHead) {
|
|
@@ -239,7 +248,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
239
248
|
|
|
240
249
|
// Cancel any pending finalization — session is active again
|
|
241
250
|
if (sessionId) {
|
|
242
|
-
cancelPending(sessionId)
|
|
251
|
+
void ignore(cancelPending(sessionId), "cancel pending finalization");
|
|
243
252
|
}
|
|
244
253
|
|
|
245
254
|
await ActiveEngine.register(room, channel);
|
|
@@ -266,7 +275,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
266
275
|
// A backend that cannot start must not take the turn down.
|
|
267
276
|
const next = cursor.advance("provider");
|
|
268
277
|
log.warn({ room, err: String(err), to: next && describeEntry(next) }, "chat backend failed to start");
|
|
269
|
-
if (!next) throw
|
|
278
|
+
if (!next) throw asError(err);
|
|
270
279
|
handoff = await buildHandoff(userMessage);
|
|
271
280
|
continue;
|
|
272
281
|
}
|
|
@@ -318,7 +327,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
318
327
|
messageId = await Message.save({ ...saveParams, metadata: undefined });
|
|
319
328
|
}
|
|
320
329
|
await Session.touch(sessionId);
|
|
321
|
-
Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel })
|
|
330
|
+
void ignore(Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }), "accumulate session metadata");
|
|
322
331
|
}
|
|
323
332
|
result = { result: ev.text, costUsd, turns, messageId };
|
|
324
333
|
break;
|
|
@@ -340,11 +349,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
340
349
|
}
|
|
341
350
|
}
|
|
342
351
|
} catch (err) {
|
|
343
|
-
await ActiveEngine.unregister(room)
|
|
352
|
+
await ignore(ActiveEngine.unregister(room), "unregister active-engine");
|
|
344
353
|
clearLongRunningTimer();
|
|
345
354
|
inFlight = false;
|
|
346
355
|
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
347
|
-
throw
|
|
356
|
+
throw asError(err);
|
|
348
357
|
}
|
|
349
358
|
|
|
350
359
|
// Re-read the backend session id post-send so finalize/DB target it.
|
|
@@ -379,7 +388,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
379
388
|
}
|
|
380
389
|
}
|
|
381
390
|
await teardown();
|
|
382
|
-
await ActiveEngine.unregister(room)
|
|
391
|
+
await ignore(ActiveEngine.unregister(room), "unregister active-engine");
|
|
383
392
|
},
|
|
384
393
|
};
|
|
385
394
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates a user turn when time has visibly passed.
|
|
3
|
+
*
|
|
4
|
+
* A resumed session replays its transcript with no timestamps, so a reply from
|
|
5
|
+
* three days ago reads as present-tense — ask "what should I wear?" twice in a
|
|
6
|
+
* week and the model happily repeats Monday's weather. The marker is prefixed
|
|
7
|
+
* onto the turn that is sent AND stored, so the transcript dates itself from
|
|
8
|
+
* then on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MINUTE_MS = 60_000;
|
|
12
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
13
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
14
|
+
|
|
15
|
+
/** Gap that counts as "time has passed" even within one day. */
|
|
16
|
+
export const GAP_THRESHOLD_MS = 2 * HOUR_MS;
|
|
17
|
+
|
|
18
|
+
const LOCALE = "en-US";
|
|
19
|
+
|
|
20
|
+
function localDay(date: Date, timezone: string): string {
|
|
21
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
22
|
+
year: "numeric",
|
|
23
|
+
month: "2-digit",
|
|
24
|
+
day: "2-digit",
|
|
25
|
+
timeZone: timezone,
|
|
26
|
+
}).format(date);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function plural(n: number, unit: string): string {
|
|
30
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Floored, never rounded up: the absolute stamp alongside it carries the
|
|
34
|
+
* precision, so this should not claim more time passed than actually did. */
|
|
35
|
+
function elapsed(ms: number): string {
|
|
36
|
+
if (ms >= DAY_MS) return plural(Math.floor(ms / DAY_MS), "day");
|
|
37
|
+
if (ms >= HOUR_MS) return plural(Math.floor(ms / HOUR_MS), "hour");
|
|
38
|
+
return plural(Math.max(1, Math.floor(ms / MINUTE_MS)), "minute");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The prefix for this turn, or null when the conversation is still continuous.
|
|
43
|
+
* Fires when the local date has changed or the gap exceeds the threshold.
|
|
44
|
+
*/
|
|
45
|
+
export function gapMarker(now: Date, last: Date | null, timezone: string): string | null {
|
|
46
|
+
if (!last) return null;
|
|
47
|
+
|
|
48
|
+
const gap = now.getTime() - last.getTime();
|
|
49
|
+
const dayChanged = localDay(now, timezone) !== localDay(last, timezone);
|
|
50
|
+
if (gap < GAP_THRESHOLD_MS && !dayChanged) return null;
|
|
51
|
+
|
|
52
|
+
const stamp = new Intl.DateTimeFormat(LOCALE, {
|
|
53
|
+
weekday: "long",
|
|
54
|
+
year: "numeric",
|
|
55
|
+
month: "long",
|
|
56
|
+
day: "numeric",
|
|
57
|
+
hour: "numeric",
|
|
58
|
+
minute: "2-digit",
|
|
59
|
+
timeZone: timezone,
|
|
60
|
+
}).format(now);
|
|
61
|
+
|
|
62
|
+
return `[${stamp} — ${elapsed(gap)} since the last message]`;
|
|
63
|
+
}
|
package/src/chat/repl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as readline from "readline";
|
|
2
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
2
3
|
import { createChatEngine } from "./engine";
|
|
3
4
|
import { runMigrations } from "../db/migrate";
|
|
4
5
|
import { closeDb } from "../db/connection";
|
|
@@ -118,7 +119,7 @@ export async function startRepl(
|
|
|
118
119
|
try {
|
|
119
120
|
await runMigrations();
|
|
120
121
|
} catch (err) {
|
|
121
|
-
const msg =
|
|
122
|
+
const msg = errMsg(err);
|
|
122
123
|
console.error(`Failed to connect to postgres: ${msg}`);
|
|
123
124
|
console.error(`Set database_url in ~/.niahere/config.yaml or run \`nia init\``);
|
|
124
125
|
process.exit(1);
|
|
@@ -218,7 +219,7 @@ export async function startRepl(
|
|
|
218
219
|
process.stdout.write("\n\n");
|
|
219
220
|
} catch (err) {
|
|
220
221
|
status.stop();
|
|
221
|
-
const msg =
|
|
222
|
+
const msg = errMsg(err);
|
|
222
223
|
console.error(`\n${DIM}error:${RESET} ${msg}\n`);
|
|
223
224
|
}
|
|
224
225
|
}
|
|
@@ -252,9 +253,8 @@ export async function startRepl(
|
|
|
252
253
|
rl.on("close", async () => {
|
|
253
254
|
console.log(`\n${DIM}bye${RESET}`);
|
|
254
255
|
await engine.close();
|
|
255
|
-
closeDb()
|
|
256
|
-
|
|
257
|
-
.finally(() => process.exit(0));
|
|
256
|
+
await ignore(closeDb(), "close db on exit");
|
|
257
|
+
process.exit(0);
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
process.on("SIGINT", () => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readRawConfig, updateRawConfig } from "../utils/config";
|
|
2
|
+
|
|
3
|
+
const USAGE = [
|
|
4
|
+
"Usage: nia config <set|get|list>",
|
|
5
|
+
" nia config set <key> <value> — set a config value",
|
|
6
|
+
" nia config get <key> — get a config value",
|
|
7
|
+
" nia config list — show all config",
|
|
8
|
+
].join("\n");
|
|
9
|
+
|
|
10
|
+
/** Booleans and integers are written as their real types, not strings. */
|
|
11
|
+
function parseValue(raw: string): unknown {
|
|
12
|
+
if (raw === "true") return true;
|
|
13
|
+
if (raw === "false") return false;
|
|
14
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expand `a.b.c` into `{ a: { b: { c: value } } }` for a merging write. */
|
|
19
|
+
function nest(key: string, value: unknown): Record<string, unknown> {
|
|
20
|
+
const parts = key.split(".");
|
|
21
|
+
const root: Record<string, unknown> = {};
|
|
22
|
+
let cursor = root;
|
|
23
|
+
for (const part of parts.slice(0, -1)) {
|
|
24
|
+
cursor[part] = {};
|
|
25
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
cursor[parts[parts.length - 1]!] = value;
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPath(raw: unknown, key: string): unknown {
|
|
32
|
+
let val = raw;
|
|
33
|
+
for (const part of key.split(".")) {
|
|
34
|
+
if (val && typeof val === "object") val = (val as Record<string, unknown>)[part];
|
|
35
|
+
else return undefined;
|
|
36
|
+
}
|
|
37
|
+
return val;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function configCommand(sub?: string, key?: string, value?: string): Promise<void> {
|
|
41
|
+
if (sub === "set" && key) {
|
|
42
|
+
if (!value) {
|
|
43
|
+
console.error("Usage: nia config set <key> <value>");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
updateRawConfig(nest(key, parseValue(value)));
|
|
47
|
+
console.log(`${key} = ${value}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sub === "get" && key) {
|
|
52
|
+
const val = readPath(readRawConfig(), key);
|
|
53
|
+
if (val === undefined) {
|
|
54
|
+
console.log(`${key}: (not set)`);
|
|
55
|
+
} else if (typeof val === "object") {
|
|
56
|
+
const yaml = (await import("js-yaml")).default;
|
|
57
|
+
console.log(yaml.dump(val, { lineWidth: -1 }).trim());
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`${key} = ${val}`);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!sub || sub === "list") {
|
|
65
|
+
const yaml = (await import("js-yaml")).default;
|
|
66
|
+
console.log(yaml.dump(readRawConfig(), { lineWidth: -1 }).trim());
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(USAGE);
|
|
71
|
+
}
|