@inetafrica/open-claudia 3.2.1 → 3.3.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/core/actions.js CHANGED
@@ -753,6 +753,86 @@ async function handleAction(envelope) {
753
753
  await send(`Type the rule to apply to ${target}.\nFor example: “may share our office hours and address”, or “never discuss pricing”.\n\nOr send /cancel to skip.`);
754
754
  return;
755
755
  }
756
+ if (d.startsWith("vfy:")) {
757
+ // "Verify contact…" on an approval card for an unverified auto-added
758
+ // speaker. Owner-only (transport-gated on the callback envelope — the one
759
+ // surface the agent can't forge). Offers the same-name candidate notes as
760
+ // buttons; the actual state change happens in the vfl: pick handler via
761
+ // core/verify-contact, which re-validates everything.
762
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — contact verification is restricted.");
763
+ const id = d.split(":")[1];
764
+ const approvals = require("./approvals");
765
+ const rec = approvals.read(id);
766
+ if (!rec || !rec.personId) return send("That request expired — nothing to verify from it.");
767
+ const people = require("./people");
768
+ const person = people.findById(rec.personId);
769
+ if (!person) return send("I no longer have a contact record for them.");
770
+ if (!person.autoCreated) return send(`${person.name} is already verified.`);
771
+ const candidates = require("./verify-contact").candidateEntitiesFor(person).slice(0, 6);
772
+ // Button payloads are INDEX-based: Telegram caps callback_data at 64
773
+ // bytes and slugs alone can be 60 — the picked index resolves against
774
+ // this snapshot, so a changed entity set can't shift what a tap means.
775
+ state.pendingVerify = {
776
+ aprId: rec.id,
777
+ personId: person.id,
778
+ personName: person.name,
779
+ candidates: candidates.map((c) => ({ slug: c.slug, name: c.name })),
780
+ armedAt: Date.now(),
781
+ };
782
+ saveState();
783
+ const rows = candidates.map((c, i) => ([{ text: `🔗 Link to ${c.slug}`.slice(0, 60), callback_data: `vfl:${rec.id}:${i}` }]));
784
+ rows.push([{ text: "✅ Just verify — this is a new contact", callback_data: `vfl:${rec.id}:self` }]);
785
+ rows.push([{ text: "✖️ Cancel", callback_data: `vfl:${rec.id}:x` }]);
786
+ await send(
787
+ `🪪 Verify ${person.name}?\n` +
788
+ (candidates.length
789
+ ? `They share a name with ${candidates.length === 1 ? "an existing note" : `${candidates.length} existing notes`} — link them if this is the same person, or keep them separate.\n`
790
+ : `No existing note matches their name — verifying keeps their current note as canonical.\n`) +
791
+ `Verifying settles WHO they are. It grants no permissions — use “Make a rule” or /guardrails for that.`,
792
+ { keyboard: { inline_keyboard: rows } }
793
+ );
794
+ return;
795
+ }
796
+ if (d.startsWith("vfl:")) {
797
+ // Verification pick. Owner-only; one-shot against the armed snapshot with
798
+ // the same 5-minute TTL as rule authoring. verifyContact() re-validates
799
+ // the person and target at execution time, so a stale snapshot can only
800
+ // refuse — never mislink.
801
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — contact verification is restricted.");
802
+ const parts = d.split(":");
803
+ const id = parts[1];
804
+ const pick = parts[2];
805
+ const pend = state.pendingVerify;
806
+ const VERIFY_TTL_MS = 5 * 60 * 1000;
807
+ if (!pend || pend.aprId !== id || (pend.armedAt && Date.now() - pend.armedAt > VERIFY_TTL_MS)) {
808
+ state.pendingVerify = null; saveState();
809
+ return send("That verify prompt is stale — tap “Verify contact…” on the card again.");
810
+ }
811
+ state.pendingVerify = null; saveState();
812
+ if (pick === "x") return send("Okay — left unverified.");
813
+ let linkSlug = null;
814
+ if (pick !== "self") {
815
+ const chosen = pend.candidates[parseInt(pick, 10)];
816
+ if (!chosen) return send("That button is stale — tap “Verify contact…” on the card again.");
817
+ linkSlug = chosen.slug;
818
+ }
819
+ const result = require("./verify-contact").verifyContact({
820
+ personId: pend.personId,
821
+ linkSlug,
822
+ by: String(envelope.channelId),
823
+ via: "approval-card",
824
+ });
825
+ if (!result.ok) {
826
+ return send(result.already
827
+ ? `${pend.personName} is already verified.`
828
+ : `Couldn't verify: ${result.reason}`);
829
+ }
830
+ return send(
831
+ `🪪 Verified ${pend.personName}` +
832
+ (linkSlug ? ` — linked to ${result.slug}${result.superseded ? ` (auto-stub ${result.superseded} retired)` : ""}.` : ` — their note is now canonical.`) +
833
+ `\nVerification settles identity only; permissions still come from mandates and rules.`
834
+ );
835
+ }
756
836
  if (d.startsWith("grd:")) {
757
837
  // Guardrail capture buttons (Track C). Owner-only: standing rules layer
758
838
  // above lessons and are never editable by an external speaker.
package/core/enforcer.js CHANGED
@@ -360,7 +360,27 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
360
360
  `<pre>${esc(String(rec.command || ""))}</pre>`,
361
361
  ];
362
362
  }
363
- const lines = [head, ``, ...askBlock, ...body, reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
363
+ // Unverified auto-added speaker → the card carries a one-tap way for the
364
+ // owner to settle WHO this is (link to an existing note, or bless the stub).
365
+ // Computed live from the people record — after verification the row (and
366
+ // the warning line) disappear from subsequent cards on their own.
367
+ let unverifiedLine = "";
368
+ let verifyRow = null;
369
+ try {
370
+ if (rec.personId) {
371
+ const person = require("./people").findById(rec.personId);
372
+ if (person && person.autoCreated && !person.isOwner) {
373
+ const candidates = require("./verify-contact").candidateEntitiesFor(person);
374
+ unverifiedLine = candidates.length
375
+ ? `⚠️ Unverified auto-added contact. Possible existing note: <b>${esc(candidates[0].slug)}</b> — NOT linked until you verify.`
376
+ : `⚠️ Unverified auto-added contact — I only know them from this chat.`;
377
+ verifyRow = [{ text: "🪪 Verify contact…", callback_data: `vfy:${rec.id}` }];
378
+ }
379
+ }
380
+ } catch (e) { /* card still posts without the verify affordance */ }
381
+ const lines = [head, ``, ...askBlock, ...body,
382
+ unverifiedLine ? `\n${unverifiedLine}` : ``,
383
+ reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
364
384
  const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
365
385
  const no = kind === "triage" ? "⛔ No, decline" : "⛔ Deny";
366
386
  const keyboard = { inline_keyboard: [
@@ -370,6 +390,7 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
370
390
  ],
371
391
  [
372
392
  { text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
393
+ ...(verifyRow || []),
373
394
  ],
374
395
  ] };
375
396
  try {
package/core/handlers.js CHANGED
@@ -1611,14 +1611,18 @@ register({
1611
1611
  // Stop pending preview flushes but keep statusMessageId/Channel: the
1612
1612
  // killed run's delivery unwind edits the preview to drop its footer.
1613
1613
  if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1614
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1614
+ // Clearing the interval here orphans the run's own stopper (ownership
1615
+ // check makes it a no-op), so the explicit typing:stop must go out now.
1616
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1617
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1615
1618
  await sideCancellation;
1616
1619
  await send(`Cancelled.${queuedNote()}`);
1617
1620
  } else if (state.preparingRun) {
1618
1621
  // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1619
1622
  // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1620
1623
  state.cancelRequested = true;
1621
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1624
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1625
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1622
1626
  await sideCancellation;
1623
1627
  await send(`Cancelled.${queuedNote()}`);
1624
1628
  } else if (await sideCancellation) await send("Cancelled side response.");
@@ -2462,7 +2466,7 @@ function formatPersonShort(p) {
2462
2466
  }
2463
2467
 
2464
2468
  register({
2465
- name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove ...]",
2469
+ name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove|verify ...]",
2466
2470
  handler: async (env, { tail }) => {
2467
2471
  if (!authorized(env)) return;
2468
2472
  if (!tail || tail === "list") {
@@ -2548,7 +2552,51 @@ register({
2548
2552
  for (const h of handles) { const r = accessStore.revokeChat(h.channelId); if (r.ok) revokedCount += 1; }
2549
2553
  return send(`Removed ${person.name}. Deauthed ${revokedCount} chat${revokedCount === 1 ? "" : "s"}.`);
2550
2554
  }
2551
- send("Usage: /people | /people show <name> | /people note <name> <text> | /people add|link|unlink|set-primary|remove ... (owner)");
2555
+ // Verify an auto-added contact: settle their identity, optionally binding
2556
+ // them to an existing note. Owner-only and chat-surface-only by design —
2557
+ // there is deliberately no CLI/loopback equivalent, so identity decisions
2558
+ // can never ride agent-forgeable input. Grants no permissions.
2559
+ const verifyMatch = tail.match(/^verify\s+(.+?)(?:\s+link\s+(\S+))?$/i);
2560
+ if (verifyMatch) {
2561
+ const person = peopleStore.findById(verifyMatch[1].trim()) || peopleStore.findByName(verifyMatch[1].trim());
2562
+ if (!person) return send(`Not found: ${verifyMatch[1].trim()}`);
2563
+ const vc = require("./verify-contact");
2564
+ if (person.autoCreated && !verifyMatch[2]) {
2565
+ const candidates = vc.candidateEntitiesFor(person);
2566
+ if (candidates.length) {
2567
+ return send(
2568
+ `${person.name} shares a name with ${candidates.length === 1 ? "an existing note" : `${candidates.length} existing notes`}:\n` +
2569
+ candidates.map((c) => ` ${c.slug}`).join("\n") +
2570
+ `\n\nLink with /people verify ${person.name} link <slug>, or /people verify-new ${person.name} to keep them separate.`
2571
+ );
2572
+ }
2573
+ }
2574
+ const result = vc.verifyContact({
2575
+ personId: person.id,
2576
+ linkSlug: verifyMatch[2] || null,
2577
+ by: env.canonicalUserId || String(env.channelId),
2578
+ via: "slash-command",
2579
+ });
2580
+ if (!result.ok) return send(result.already ? `${person.name} is already verified.` : `Couldn't verify: ${result.reason}`);
2581
+ return send(
2582
+ `🪪 Verified ${person.name}` +
2583
+ (verifyMatch[2] ? ` — linked to ${result.slug}${result.superseded ? ` (auto-stub ${result.superseded} retired)` : ""}.` : ` — their note is now canonical.`) +
2584
+ `\nVerification settles identity only; permissions still come from mandates and rules.`
2585
+ );
2586
+ }
2587
+ const verifyNewMatch = tail.match(/^verify-new\s+(.+)$/i);
2588
+ if (verifyNewMatch) {
2589
+ const person = peopleStore.findById(verifyNewMatch[1].trim()) || peopleStore.findByName(verifyNewMatch[1].trim());
2590
+ if (!person) return send(`Not found: ${verifyNewMatch[1].trim()}`);
2591
+ const result = require("./verify-contact").verifyContact({
2592
+ personId: person.id,
2593
+ by: env.canonicalUserId || String(env.channelId),
2594
+ via: "slash-command",
2595
+ });
2596
+ if (!result.ok) return send(result.already ? `${person.name} is already verified.` : `Couldn't verify: ${result.reason}`);
2597
+ return send(`🪪 Verified ${person.name} — their note is now canonical.\nVerification settles identity only; permissions still come from mandates and rules.`);
2598
+ }
2599
+ send("Usage: /people | /people show <name> | /people note <name> <text> | /people add|link|unlink|set-primary|remove|verify ... (owner)");
2552
2600
  },
2553
2601
  });
2554
2602
 
package/core/loopback.js CHANGED
@@ -25,6 +25,7 @@ const introFlow = require("./intro-flow");
25
25
  const relay = require("./relay");
26
26
  const audit = require("./audit");
27
27
  const access = require("./access");
28
+ const speakerAttest = require("./speaker-attest");
28
29
  const fs2 = require("fs");
29
30
  const path2 = require("path");
30
31
  const { TRANSCRIPTS_DIR } = require("./config");
@@ -85,9 +86,17 @@ const JSON_KINDS = new Set([
85
86
  "say", "call", "tts", "transcribe",
86
87
  ]);
87
88
 
89
+ // Owner gate for identity-sensitive kinds. The claimed canonicalUserId comes
90
+ // from the CLI's env, which the agent's shell can rewrite — so the claim only
91
+ // counts when it carries the runner-minted attestation (an HMAC over the id
92
+ // with a per-boot, memory-only secret; see core/speaker-attest). No attest,
93
+ // a wrong attest, or an attest for a different id all fail closed: forging
94
+ // `OC_CANONICAL_USER_ID=<owner-id>` from a guarded external turn no longer
95
+ // reaches the people/auth/intros mutations the relationship guard depends on.
88
96
  function callerIsOwner(payload) {
89
97
  const caller = payload?.canonicalUserId;
90
98
  if (!caller) return false;
99
+ if (!speakerAttest.verify(caller, payload?.speakerAttest)) return false;
91
100
  const person = peopleStore.findByCanonicalUserId(caller);
92
101
  return !!(person && person.isOwner);
93
102
  }
@@ -347,6 +356,10 @@ async function handleJson(req, res, url, kind) {
347
356
  label: String(payload.label || payload.prompt).slice(0, 60),
348
357
  schedule: payload.schedule ? String(payload.schedule) : undefined,
349
358
  repeat: !!payload.repeat,
359
+ // Absent/invalid → the scheduler stamps its default watch timeout.
360
+ expiresAt: Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0
361
+ ? Number(payload.expiresAt)
362
+ : undefined,
350
363
  source: payload.source || "agent",
351
364
  sessionId: payload.sessionId || null,
352
365
  previousSessionId: payload.previousSessionId || null,
@@ -598,6 +611,24 @@ async function handleJson(req, res, url, kind) {
598
611
 
599
612
  if (kind === "relay-send") {
600
613
  if (!payload.text) return reply(res, 400, { error: "missing text" });
614
+ // A guarded (external) requester may pass a message TO THE OWNER — that is
615
+ // the "tell them for me" flow — but never use the bot as a relay to third
616
+ // parties: sending as the bot to anyone else is impersonation surface.
617
+ try {
618
+ const relationship = require("./relationship");
619
+ const speaker = relationship.speakerFor(adapter.type, channelId, userId);
620
+ if (relationship.guardActive(speaker)) {
621
+ const target =
622
+ (payload.targetPersonId && peopleStore.findById(payload.targetPersonId)) ||
623
+ (payload.targetPersonName && peopleStore.findByName(payload.targetPersonName)) ||
624
+ (payload.targetCanonicalUserId && peopleStore.findByCanonicalUserId(payload.targetCanonicalUserId)) ||
625
+ (payload.targetAdapter && payload.targetChannelId && peopleStore.findByHandle(payload.targetAdapter, payload.targetChannelId)) ||
626
+ null;
627
+ if (!target || !target.isOwner) {
628
+ return reply(res, 403, { error: "relay from an external contact can only go to the owner — messages to anyone else are owner-only" });
629
+ }
630
+ }
631
+ } catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
601
632
  try {
602
633
  const result = await relay.send({
603
634
  text: payload.text,
@@ -616,6 +647,16 @@ async function handleJson(req, res, url, kind) {
616
647
  }
617
648
 
618
649
  if (kind === "recent-fetch") {
650
+ // Transcript reads are owner-side memory. A guarded (external, non-owner)
651
+ // requester must not pull other channels' conversations through the CLI —
652
+ // same default-deny as file/voice egress, same fail direction.
653
+ try {
654
+ const relationship = require("./relationship");
655
+ const speaker = relationship.speakerFor(adapter.type, channelId, userId);
656
+ if (relationship.guardActive(speaker)) {
657
+ return reply(res, 403, { error: "transcript access is owner-only — an external contact cannot read other conversations" });
658
+ }
659
+ } catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
619
660
  const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
620
661
  try {
621
662
  let targetAdapter = payload.adapter;
package/core/media.js CHANGED
@@ -99,34 +99,87 @@ async function elevenLabsToVoice(clean) {
99
99
  }
100
100
  }
101
101
 
102
+ // Pack already-cleaned text into gateway-sized pieces. Long inputs used to go
103
+ // to the gateway as ONE request and blow its synthesis window (the abort was
104
+ // the binding limit, not any transport) — sentence-packed chunks keep every
105
+ // request comfortably inside it. Oversized single sentences are hard-split at
106
+ // word boundaries so one run-on sentence can't recreate the timeout.
107
+ const TTS_CHUNK_MAX = 450;
108
+ function chunkForTTS(clean, maxLen = TTS_CHUNK_MAX) {
109
+ if (clean.length <= maxLen) return [clean];
110
+ const sentences = clean.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [clean];
111
+ const chunks = [];
112
+ let buf = "";
113
+ const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
114
+ for (let piece of sentences) {
115
+ piece = piece.trim();
116
+ if (!piece) continue;
117
+ while (piece.length > maxLen) {
118
+ flush();
119
+ let cut = piece.lastIndexOf(" ", maxLen);
120
+ if (cut < maxLen / 2) cut = maxLen;
121
+ chunks.push(piece.slice(0, cut).trim());
122
+ piece = piece.slice(cut).trim();
123
+ }
124
+ if (buf && buf.length + piece.length + 1 > maxLen) flush();
125
+ buf = buf ? `${buf} ${piece}` : piece;
126
+ }
127
+ flush();
128
+ return chunks.filter(Boolean);
129
+ }
130
+
131
+ async function pocketFetchWav(input, voice, token) {
132
+ const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
133
+ method: "POST",
134
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
135
+ body: JSON.stringify({ input, voice, response_format: "wav" }),
136
+ signal: AbortSignal.timeout(120000),
137
+ });
138
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
139
+ return Buffer.from(await res.arrayBuffer());
140
+ }
141
+
102
142
  // Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
103
- // Telegram and voice transports receive a compact Ogg/Opus file.
143
+ // Telegram and voice transports receive a compact Ogg/Opus file. Long text is
144
+ // synthesized chunk-by-chunk and concatenated, so message length can no longer
145
+ // abort the request mid-synthesis.
104
146
  async function pocketToVoice(clean, voiceOverride) {
105
147
  const token = speechBearerToken();
106
148
  if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
107
- let wavPath = null;
149
+ const voice = voiceOverride || ttsVoice.getVoice();
150
+ const stem = `tts-pocket-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
151
+ const oggPath = path.join(TEMP_DIR, `${stem}.ogg`);
152
+ const wavPaths = [];
153
+ let listPath = null;
108
154
  try {
109
- const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
110
- method: "POST",
111
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
112
- body: JSON.stringify({ input: clean, voice: voiceOverride || ttsVoice.getVoice(), response_format: "wav" }),
113
- signal: AbortSignal.timeout(60000),
114
- });
115
- if (!res.ok) {
116
- console.error(`Pocket TTS failed: HTTP ${res.status}`);
117
- return null;
155
+ const chunks = chunkForTTS(clean);
156
+ for (let i = 0; i < chunks.length; i++) {
157
+ let buf;
158
+ try {
159
+ buf = await pocketFetchWav(chunks[i], voice, token);
160
+ } catch (e) {
161
+ // one retry: a transient gateway blip shouldn't waste the chunks
162
+ // already synthesized before it
163
+ buf = await pocketFetchWav(chunks[i], voice, token);
164
+ }
165
+ const wavPath = path.join(TEMP_DIR, `${stem}-${i}.wav`);
166
+ fs.writeFileSync(wavPath, buf);
167
+ wavPaths.push(wavPath);
168
+ }
169
+ if (wavPaths.length === 1) {
170
+ execSync(`"${FFMPEG}" -i "${wavPaths[0]}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
171
+ } else {
172
+ listPath = path.join(TEMP_DIR, `${stem}-list.txt`);
173
+ fs.writeFileSync(listPath, wavPaths.map((p) => `file '${p}'`).join("\n"));
174
+ execSync(`"${FFMPEG}" -f concat -safe 0 -i "${listPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 120000 });
118
175
  }
119
- const stem = `tts-pocket-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
120
- wavPath = path.join(TEMP_DIR, `${stem}.wav`);
121
- const oggPath = path.join(TEMP_DIR, `${stem}.ogg`);
122
- fs.writeFileSync(wavPath, Buffer.from(await res.arrayBuffer()));
123
- execSync(`"${FFMPEG}" -i "${wavPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
124
176
  return oggPath;
125
177
  } catch (e) {
126
178
  console.error("Pocket TTS error:", e.message);
127
179
  return null;
128
180
  } finally {
129
- if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
181
+ for (const p of wavPaths) { try { fs.unlinkSync(p); } catch (e) {} }
182
+ if (listPath) { try { fs.unlinkSync(listPath); } catch (e) {} }
130
183
  }
131
184
  }
132
185
 
@@ -303,4 +356,4 @@ async function synthSentenceMp3(text) {
303
356
  }
304
357
  }
305
358
 
306
- module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
359
+ module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, chunkForTTS, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
@@ -388,7 +388,25 @@ function applyAction(a, activeSet = null) {
388
388
 
389
389
  function applyEntityAction(e) {
390
390
  if (!e || typeof e !== "object" || !String(e.name || "").trim()) return null;
391
+ // Slug-key through the people roster: name-keyed upserts land on the
392
+ // alphabetically-first same-named entity, but the relationship guard reads
393
+ // the file the person RECORD points at (auto-added contacts get a suffixed
394
+ // stub precisely so they can't shadow an existing note). A unique name
395
+ // match routes the write to the guard-read file; an ambiguous one (two
396
+ // tracked people sharing a name) is skipped outright — enriching the wrong
397
+ // identity's note is worse than writing nothing.
398
+ let slug = "";
399
+ try {
400
+ const people = require("./people");
401
+ const wanted = String(e.name || "").trim().toLowerCase();
402
+ const matches = people.list().filter((p) => (p.name || "").trim().toLowerCase() === wanted);
403
+ if (matches.length === 1) slug = people.resolveEntitySlug(matches[0]) || "";
404
+ else if (matches.length > 1) {
405
+ return { kind: "skip", slug: "", name: e.name, type: e.type || "", note: "ambiguous: multiple tracked people share this name — not writing to either note" };
406
+ }
407
+ } catch (err) { /* roster unavailable → name-keyed fallback below */ }
391
408
  const { entity, created } = entities.upsertEntity({
409
+ slug: slug || undefined,
392
410
  name: e.name,
393
411
  type: e.type,
394
412
  aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 5) : [],
@@ -4,7 +4,7 @@ const config = require("../config");
4
4
 
5
5
  const PROVIDER_IDS = Object.freeze(["claude", "codex"]);
6
6
  const PROVIDER_ID_SET = new Set(PROVIDER_IDS);
7
- const PURPOSES = Object.freeze(["subagent", "recall", "review", "dream", "introspection", "enforcer"]);
7
+ const PURPOSES = Object.freeze(["subagent", "recall", "review", "dedup", "dream", "introspection", "enforcer"]);
8
8
  const PURPOSE_SET = new Set(PURPOSES);
9
9
  const TIERS = Object.freeze(["low", "medium", "high"]);
10
10
  const TIER_SET = new Set(TIERS);
@@ -12,6 +12,7 @@ const PURPOSE_PROVIDER_ENV = Object.freeze({
12
12
  subagent: "SUBAGENT_PROVIDER",
13
13
  recall: "RECALL_PROVIDER",
14
14
  review: "MEMORY_PROVIDER",
15
+ dedup: "MEMORY_PROVIDER",
15
16
  dream: "DREAM_PROVIDER",
16
17
  introspection: "DREAM_PROVIDER",
17
18
  enforcer: "ENFORCER_PROVIDER",
@@ -20,12 +21,14 @@ const PURPOSE_MODEL_PREFIX = Object.freeze({
20
21
  subagent: "SUBAGENT_MODEL",
21
22
  recall: "RECALL_MODEL",
22
23
  review: "PACK_REVIEW_MODEL",
24
+ dedup: "PACK_DEDUP_MODEL",
23
25
  dream: "DREAM_MODEL",
24
26
  introspection: "DREAM_MODEL",
25
27
  enforcer: "ENFORCER_MODEL",
26
28
  });
27
29
  const LEGACY_CLAUDE_MODEL_ENV = Object.freeze({
28
30
  review: "PACK_REVIEW_MODEL",
31
+ dedup: "PACK_DEDUP_MODEL",
29
32
  dream: "DREAM_MODEL",
30
33
  introspection: "DREAM_MODEL",
31
34
  enforcer: "ENFORCER_MODEL",
@@ -253,10 +253,26 @@ function ensureContact(envelope) {
253
253
  const suffix = crypto.createHash("sha1").update(`${adapterType}:${userId}`).digest("hex").slice(0, 6);
254
254
  const slug = `${entities.slugify(displayName).slice(0, 48) || "contact"}-${suffix}`;
255
255
  people.setEntitySlug(person.id, slug);
256
+
257
+ // If a same-named person note already exists, surface it as a HINT only.
258
+ // Deliberately not linked (see invariants above): a name is spoofable, so
259
+ // absorbing the note here would hand the speaker that identity's history.
260
+ // Only the owner can link, via the approval card or /people verify.
261
+ let hint = "";
262
+ try {
263
+ const fresh = people.findById(person.id) || person;
264
+ const candidates = require("./verify-contact").candidateEntitiesFor(fresh);
265
+ if (candidates.length) hint = candidates.map((c) => c.slug).slice(0, 3).join(", ");
266
+ } catch (e) { /* hint is best-effort — the stub is safe without it */ }
267
+
256
268
  entities.upsertEntity({
257
269
  slug, name: displayName, type: "person", relationship: "external",
258
- description: `Auto-added contact (unverified) — first seen in ${label}`,
259
- log: `auto-added as a contact from ${label}`,
270
+ description: hint
271
+ ? `Auto-added contact (unverified) — first seen in ${label}. Possible existing note: ${hint} — NOT linked; only the owner can verify.`
272
+ : `Auto-added contact (unverified) — first seen in ${label}`,
273
+ log: hint
274
+ ? `auto-added as a contact from ${label}; same-named note detected (${hint}) — not linked pending owner verification`
275
+ : `auto-added as a contact from ${label}`,
260
276
  });
261
277
 
262
278
  try { require("./audit").log("auto-contact", { personId: person.id, name: displayName, adapter: adapterType, userId, channelId, entitySlug: slug }); } catch (e) {}
package/core/router.js CHANGED
@@ -12,7 +12,7 @@ const { isChatAuthorized } = require("./access");
12
12
  const introFlow = require("./intro-flow");
13
13
  const { send, deleteMessage } = require("./io");
14
14
  const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
15
- const { admitRunContext, runAgent } = require("./runner");
15
+ const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
16
16
  const { transcribeAudio, cloneVoiceFromClip } = require("./media");
17
17
  const { vault } = require("./vault-store");
18
18
  const { isOnboarded, handleOnboarding } = require("./onboarding");
@@ -203,9 +203,20 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
203
203
  return !(triage && triage.held);
204
204
  }
205
205
 
206
+ // Chip-on-receipt: enforcer triage, media download, and STT all run before
207
+ // runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
208
+ // soon as a turn-bound message arrives; the run's own heartbeat supersedes it
209
+ // at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
210
+ // run is active — the chip is already owned by that turn.
211
+ function startPreTurnTyping(state) {
212
+ if (state.runningProcess || state.preparingRun) return () => {};
213
+ try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
214
+ }
215
+
206
216
  async function handleVoice(envelope) {
207
217
  if (isDuplicate(envelope)) return;
208
218
  const state = currentState();
219
+ let stopPreTurnTyping = () => {};
209
220
  try {
210
221
  const media = envelope.media?.[0];
211
222
  if (!media) return;
@@ -223,7 +234,7 @@ async function handleVoice(envelope) {
223
234
  purpose: "foreground",
224
235
  attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
225
236
  });
226
- envelope.adapter.typing(envelope.channelId).catch(() => {});
237
+ stopPreTurnTyping = startPreTurnTyping(state);
227
238
  const tStart = Date.now();
228
239
  const oggPath = await envelope.adapter.downloadMedia(media);
229
240
  const tAfterDownload = Date.now();
@@ -243,11 +254,13 @@ async function handleVoice(envelope) {
243
254
  voiceStream: envelope.adapter?.type === "voice",
244
255
  });
245
256
  } catch (err) { await send(`Voice failed: ${err.message}`); }
257
+ finally { stopPreTurnTyping(); }
246
258
  }
247
259
 
248
260
  async function handleAudio(envelope) {
249
261
  if (isDuplicate(envelope)) return;
250
262
  const state = currentState();
263
+ let stopPreTurnTyping = () => {};
251
264
  try {
252
265
  const media = envelope.media?.[0];
253
266
  if (!media) return;
@@ -260,7 +273,7 @@ async function handleAudio(envelope) {
260
273
  purpose: "foreground",
261
274
  attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
262
275
  });
263
- envelope.adapter.typing(envelope.channelId).catch(() => {});
276
+ stopPreTurnTyping = startPreTurnTyping(state);
264
277
  const p = await envelope.adapter.downloadMedia(media);
265
278
  const t = await transcribeAudio(p);
266
279
  try { fs.unlinkSync(p); } catch (e) {}
@@ -272,6 +285,7 @@ async function handleAudio(envelope) {
272
285
  attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
273
286
  });
274
287
  } catch (err) { await send(`Audio failed: ${err.message}`); }
288
+ finally { stopPreTurnTyping(); }
275
289
  }
276
290
 
277
291
  // A clip the owner sent right after /voice clone. Upload it to the speech
@@ -297,6 +311,7 @@ async function captureVoiceClone(envelope, media) {
297
311
  async function handlePhoto(envelope) {
298
312
  if (isDuplicate(envelope)) return;
299
313
  const state = currentState();
314
+ let stopPreTurnTyping = () => {};
300
315
  try {
301
316
  const items = envelope.media || [];
302
317
  if (!items.length) return;
@@ -304,6 +319,7 @@ async function handlePhoto(envelope) {
304
319
  purpose: "foreground",
305
320
  attachments: items.map((item) => ({ type: "image", mimeType: item.mimeType || "image/*", fileName: item.fileName || null })),
306
321
  });
322
+ stopPreTurnTyping = startPreTurnTyping(state);
307
323
  const paths = [];
308
324
  for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
309
325
  const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
@@ -327,11 +343,13 @@ async function handlePhoto(envelope) {
327
343
  })),
328
344
  });
329
345
  } catch (err) { await send(`Image failed: ${err.message}`); }
346
+ finally { stopPreTurnTyping(); }
330
347
  }
331
348
 
332
349
  async function handleDocument(envelope) {
333
350
  if (isDuplicate(envelope)) return;
334
351
  const state = currentState();
352
+ let stopPreTurnTyping = () => {};
335
353
  try {
336
354
  const media = envelope.media?.[0];
337
355
  if (!media) return;
@@ -342,6 +360,7 @@ async function handleDocument(envelope) {
342
360
  purpose: "foreground",
343
361
  attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
344
362
  });
363
+ stopPreTurnTyping = startPreTurnTyping(state);
345
364
  const savePath = await envelope.adapter.downloadMedia(media);
346
365
  const fileName = media.fileName || path.basename(savePath);
347
366
  const mime = media.mimeType || "";
@@ -361,6 +380,7 @@ async function handleDocument(envelope) {
361
380
  attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
362
381
  });
363
382
  } catch (err) { await send(`Failed: ${err.message}`); }
383
+ finally { stopPreTurnTyping(); }
364
384
  }
365
385
 
366
386
  // Provider-agnostic capture of a credential pasted into chat with no auth
@@ -605,7 +625,11 @@ async function handleText(envelope) {
605
625
  // stop, instead of forwarding a secret to the model (Codex refuses those).
606
626
  if (await captureLooseCredential(text.trim(), envelope)) return;
607
627
 
608
- if (!(await admitGuardedTurn(text, envelope, state))) return;
628
+ const stopPreTurnTyping = startPreTurnTyping(state);
629
+ let admitted;
630
+ try { admitted = await admitGuardedTurn(text, envelope, state); }
631
+ catch (e) { stopPreTurnTyping(); throw e; }
632
+ if (!admitted) { stopPreTurnTyping(); return; }
609
633
 
610
634
  const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
611
635
  const attachments = [];