@inetafrica/open-claudia 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/core/config.js +6 -5
- package/core/dream.js +13 -5
- package/core/entities.js +68 -15
- package/core/pack-review.js +31 -3
- package/core/people.js +24 -0
- package/core/queue-drain.js +25 -0
- package/core/recall/discoverer.js +22 -2
- package/core/router.js +6 -3
- package/core/runner.js +37 -9
- package/core/system-prompt.js +53 -2
- package/package.json +10 -3
- package/test-persona-packs.js +126 -0
- package/test-persona-pipeline.js +79 -0
- package/test-queue-routing.js +52 -0
- package/test-recall-evolution.js +214 -0
- package/test-recall-relationship-gate.js +42 -0
- package/test-speaker-persona.js +89 -0
- package/test-unified-identity.js +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.12.0
|
|
4
|
+
- **Persona packs — the bot now adapts voice + scope to WHO it's talking to (Phase 2).** An entity note about a *person* is upgraded into a persona pack: the same file gains four optional sections — **Role** (what they do), **Style** (how to talk to them), **Knows** (what to assume vs explain), **Mandate** (for non-owners: what the bot may do/discuss on their behalf) — plus a `relationship: owner|trusted|external` frontmatter scalar. The upgrade is strictly **additive (risk R7)**: an ordinary entity with no persona data serializes **byte-identically** to before, so places/projects/orgs/systems are untouched. `core/entities.js` learns `PERSONA_SECTIONS`, `RELATIONSHIPS`, `normalizeRelationship`, an `emptySections()` backfill for notes written before the upgrade, and persona params on `upsertEntity` that each replace their section wholesale (like Notes). FTS indexes the descriptive sections (Role/Style/Knows) so a persona is matchable by content, but **Mandate is deliberately not indexed** — it's policy, not description, and must not surface a pack on an unrelated mention.
|
|
5
|
+
- **Per-turn speaker persona, injected cache-safely (2.2).** `buildSpeakerPersonaBlock` resolves the *current* speaker (deterministically, not by keyword match) and injects their Role/Style/Knows — and, for a non-owner, their Mandate — so the model shapes its voice and scope every turn. It rides the **uncached dynamic tail** (risk R13), never `buildSystemPrompt`, so persona edits never bust the prompt-cache prefix, and dedupes once per (channel, session, entity-version) like packs/entities (a compaction or a persona edit re-injects). An **owner's Mandate is never injected** (the owner has no guardrails — injecting one would be noise).
|
|
6
|
+
- **People ↔ persona binding (2.1).** `people.js` gains an `entitySlug` pointer (a person can point at a differently-named pack) with `setEntitySlug` + a pure `resolveEntitySlug` (explicit pointer, else slug-from-name); surfaced in `roster()`.
|
|
7
|
+
- **Write-side population (2.3).** The post-turn reviewer (`pack-review.js`) and the nightly dream (`dream.js`) both learn to fill persona sections: their prompts describe the four sections + the relationship scalar with explicit guardrails ("never invent an owner mandate; never copy one person's guardrail onto another"), their JSON schemas carry the new fields, and their apply paths thread them into `upsertEntity` (each replacing its section wholesale; null/omit leaves it intact).
|
|
8
|
+
- **Relationship-gated cross-conversation recall (2.4, risk R6).** Episodic recall reaches across conversations into the owner's project transcripts, so surfacing it to a non-owner is a cross-tenant leak. `episodesAllowedForSpeaker()` makes cross-conversation episodes **owner-only and fail-closed**: an unknown speaker or any resolution error suppresses episodes; only a positively-identified owner (or no speaker context at all — CLI/tests) pulls them. Same-conversation packs/entities are unaffected.
|
|
9
|
+
- **Provenance stamps (2.5).** Appended Log/Journal lines from a **non-owner** contributor are stamped `(via <name>)` (the owner is the default author — tagging them would be noise), so a topic's history shows where an external/trusted colleague's contribution came from. Resolved from the live store at the reviewer call site (the reviewer applies in an async continuation) and threaded through as a `source`.
|
|
10
|
+
- **Tests.** Four new hermetic suites, all wired into `npm test`: `test-persona-packs.js` (round-trip + R7 byte-identical ordinary entity + FTS mandate-exclusion), `test-speaker-persona.js` (owner-Mandate suppression, non-owner Mandate shown, dedupe + re-inject on edit, silent for no-persona/unknown speaker), `test-persona-pipeline.js` (reviewer + dream populate/refresh persona wholesale + provenance), `test-recall-relationship-gate.js` (owner allowed, non-owner/unknown blocked, no-context ungated). Full suite green.
|
|
11
|
+
- **Agent-space / OpenClaw:** no new deps, no new required env; Docker image builds identically. Behaviour is inert until a person actually has persona data — existing deployments are unaffected until the reviewer/dream (or the owner, editing `entities/<slug>.md`) fills a pack. The independent **fail-closed enforcer** that hard-enforces Mandate at the outbound choke point is still Phase 3.
|
|
12
|
+
|
|
13
|
+
## v2.11.0
|
|
14
|
+
- **Unified multi-channel identity is now LIVE by default (Phases 0 + 1).** v2.10.0 shipped the split-brain fix inert behind `OC_UNIFIED_IDENTITY` (default off). This release flips the default **on** and lands the Phase 1 queue-routing fix that made it safe to do so. The owner's Telegram, Kazee and voice channels now resolve to one canonical person out of the box — one state, one session, one transcript, one run-lock — so the bot stops fracturing into a separate assistant per channel. The flag stays as an operator **kill-switch** (`OC_UNIFIED_IDENTITY=0` + restart) for reversibility (risk R14); it is no longer a hold-back gate.
|
|
15
|
+
- **Person-scoped queue routing (Phase 1, 1.2) — the blocker that kept the flag off.** Under unified identity the owner's channels share one run-lock and message queue, so a message sent from one channel while a run is in flight on another used to drain in the *finishing* turn's async scope — a Kazee follow-up could be answered in the Telegram chat. Each queued message now **captures its origin channel** at enqueue time, and `drainQueuedMessages` peels off one same-origin run at a time (requeuing the rest so the natural close→drain tail-recursion handles the next group), scoping each drained reply to `chatContext.run(origin, …)`. Replies always route back to the channel the message came from; same-origin batches behave exactly as before. Grouping logic extracted to a pure, unit-tested `core/queue-drain.js`.
|
|
16
|
+
- **Cross-channel dedup (0.3).** `router.isDuplicate` is re-keyed from `adapter:channel:msg` to `canonical:adapter:msg` — a message redelivered against a different channel that resolves to the same person is now caught once the owner's channels are unified (adapter stays in the key, so distinct messages that merely share a numeric id across transports are never collapsed).
|
|
17
|
+
- **First-contact migration (0.2 / 1.1) fires automatically.** With the flag on, the first inbound message from a previously-siloed owner channel folds its pre-existing state + sessions into the owner bucket — non-destructive, idempotent, and backed up to `~/.open-claudia/backups/` first (unchanged from v2.10.0, now actually reached). Pre-migration per-channel transcripts remain as separate files and stay reachable via `transcript-search --all`; new history accretes under the unified person id (1.3).
|
|
18
|
+
- **Tests.** `test-unified-identity.js` gains a `default` (unset ⇒ on) child to lock the new default and proves an explicit `0` still reverts to strict per-channel resolution; new `test-queue-routing.js` covers origin keying, same-origin batching, mixed-origin split/requeue, and legacy (pre-capture) fallback. Full suite green.
|
|
19
|
+
- **Agent-space / OpenClaw:** no new deps or required env; Docker image builds identically. Existing single-owner deployments see unified behaviour for the owner's own channels only — strangers still resolve to their own isolated bucket (the per-person guardrail arrives in Phase 3).
|
|
20
|
+
|
|
3
21
|
## v2.10.0
|
|
4
22
|
- **Unified multi-channel identity — foundation, gated OFF by default (Phase 0).** An owned bot is meant to be one mind across every channel its owner reaches it on; today it split-brains because conversation state (session, transcript, run-lock, queue) is keyed per `transport:channelId`, so an unlinked channel (voice bridge, a stray Kazee DM) becomes a separate assistant with its own history. This release lands the identity-resolution foundation behind a single flag, `OC_UNIFIED_IDENTITY` (default **off**). **With the flag off nothing changes** — every new code path is gated, so publishing this version is behaviourally identical to v2.9.3 until an operator sets `OC_UNIFIED_IDENTITY=1` and restarts. Boot log now prints `Unified identity: on|off` for visibility.
|
|
5
23
|
- **Resolution seam (0.1).** `identity.canonicalForChannel` — the single chokepoint every adapter (Telegram/Kazee/voice) routes through — gains a flag-gated branch: a *configured owner channel* (one whose `transport:channelId` matches an operator-declared owner id — a `TELEGRAM_CHAT_ID`, `KAZEE_OWNER_USER_ID`, or `VOICE_OWNER_USER_ID`) with no explicit `/link` now resolves to the single owner canonical (the owner's first-linked handle, matching `people.linkHandle`'s primary convention). Because it's the one seam, state/transcript/scheduler all unify downstream for free. Owner detection uses only trusted, operator-set signals — never a guess — so a stranger's channel can never be merged by mistake (verified: a non-owner channel still resolves to its own default bucket).
|
package/core/config.js
CHANGED
|
@@ -94,11 +94,12 @@ const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL ||
|
|
|
94
94
|
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
|
|
95
95
|
const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
|
|
96
96
|
const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
|
|
97
|
-
// Unified multi-channel identity
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// OC_UNIFIED_IDENTITY=
|
|
101
|
-
|
|
97
|
+
// Unified multi-channel identity. Default ON: the owner's Telegram, Kazee and
|
|
98
|
+
// voice channels resolve to one canonical person, sharing one state, session,
|
|
99
|
+
// transcript and run-lock (the split-brain fix). The flag remains as an
|
|
100
|
+
// operator kill-switch for reversibility (R14) — set OC_UNIFIED_IDENTITY=0 in
|
|
101
|
+
// .env and restart to fall back to strict per-channel behaviour.
|
|
102
|
+
const UNIFIED_IDENTITY = configTruthy(config.OC_UNIFIED_IDENTITY || process.env.OC_UNIFIED_IDENTITY, true);
|
|
102
103
|
const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
|
|
103
104
|
const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
|
|
104
105
|
const WHISPER_CLI = config.WHISPER_CLI || "";
|
package/core/dream.js
CHANGED
|
@@ -320,7 +320,7 @@ Your job — decide what consolidation, if any, is warranted:
|
|
|
320
320
|
3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything. NOTE: a separate deterministic pass already auto-files packs whose slug is a prefix-child of another (e.g. foo-bar under foo), so DON'T spend a parents op on those — reserve parents (and umbrellas) for semantic groupings the slug prefix can't catch (differently-named siblings of one theme).
|
|
321
321
|
4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
|
|
322
322
|
5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
|
|
323
|
-
6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name.
|
|
323
|
+
6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name. For a PERSON, this doubles as their persona pack: you may also consolidate role/style/knows (how to talk to them, what they know) and — for non-owners only — mandate (what the assistant may do/discuss on their behalf). Set relationship (owner|trusted|external) only if clearly established. Keep each persona field tight; never invent an owner mandate; never copy one person's guardrail onto another. Send only the fields you're changing; null/omit the rest.
|
|
324
324
|
7. archive: retire packs that have gone cold — long unused AND rarely used over their life (consult last_used and the usage count). Archiving moves a pack out of the live index (reversibly, backed up) so it stops adding recall noise. ONLY propose packs idle ≥${ARCHIVE_IDLE_DAYS} days and used ≤${ARCHIVE_MAX_USAGE}× total; never an umbrella/parent pack that still has children; when in doubt, leave it. Give a one-line reason.
|
|
325
325
|
8. lessons: keep the always-loaded lessons few and sharp — they cost context on EVERY turn. dedupe near-identical lessons (op "remove" the weaker, keep the better wording; or op "edit" to merge two into one tight line); tighten a clumsy lesson with op "edit". If the count exceeds the cap of ${lessons.MAX_LESSONS}, demote the weakest down to the cap — prefer lessons that are low-reinforced AND whose fact is safely captured in the pack named in their (src) (they will still surface by topic-match), and NEVER remove a frequently-reinforced lesson (those are actively preventing a repeat mistake). Even under the cap you may remove a lesson clearly redundant with its source pack that has stayed at 0 reinforcements for a long time, but be conservative — when in doubt, keep it. Reference lessons by their exact current text.
|
|
326
326
|
9. persona: evolve the persona GENTLY — keep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
|
|
@@ -341,7 +341,7 @@ Reply with ONLY a JSON object, no prose, no code fences:
|
|
|
341
341
|
"parents": [{"pack": "<dir>", "parent": "<dir>"}],
|
|
342
342
|
"retag": [{"pack": "<dir>", "description": "<one specific line>", "tags": ["..."]}],
|
|
343
343
|
"entity_merges": [{"into": "<slug>", "from": ["<slug>"], "notes": "<merged Notes or null>"}],
|
|
344
|
-
"entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes>"}],
|
|
344
|
+
"entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes or null>", "relationship": "<owner|trusted|external, people only, or null>", "role": "<people only, or null>", "style": "<people only, or null>", "knows": "<people only, or null>", "mandate": "<non-owner people only, or null>"}],
|
|
345
345
|
"archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
|
|
346
346
|
"lessons": [{"op": "remove", "text": "<exact current lesson text>", "reason": "<duplicate of … / safely in pack … / cold>"}],
|
|
347
347
|
"persona": null,
|
|
@@ -582,9 +582,17 @@ function applyDream(decision, backupRoot) {
|
|
|
582
582
|
for (const en of decision.entity_notes || []) {
|
|
583
583
|
try {
|
|
584
584
|
const ent = en?.entity && !gone.has(en.entity) && entities.readEntity(en.entity);
|
|
585
|
-
if (!ent
|
|
586
|
-
|
|
587
|
-
|
|
585
|
+
if (!ent) continue;
|
|
586
|
+
// Persona-pack fields consolidate alongside Notes (people only). Each
|
|
587
|
+
// replaces its section wholesale when a non-empty string is supplied.
|
|
588
|
+
const persona = {};
|
|
589
|
+
for (const k of ["relationship", "role", "style", "knows", "mandate"]) {
|
|
590
|
+
if (typeof en[k] === "string" && en[k].trim()) persona[k] = en[k];
|
|
591
|
+
}
|
|
592
|
+
const hasNotes = typeof en.notes === "string" && en.notes.trim();
|
|
593
|
+
if (!hasNotes && Object.keys(persona).length === 0) continue;
|
|
594
|
+
entities.upsertEntity({ name: ent.name, ...(hasNotes ? { notes: en.notes } : {}), ...persona });
|
|
595
|
+
lines.push(`🔗 Refreshed ${Object.keys(persona).length ? "persona + notes" : "notes"} on ${ent.name}`);
|
|
588
596
|
} catch (e) { console.warn(`[dream] entity notes failed: ${e.message}`); }
|
|
589
597
|
}
|
|
590
598
|
|
package/core/entities.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// Entity memory: short living notes on the people, places, projects,
|
|
2
2
|
// orgs and systems that come up in conversation (a Honcho-style store,
|
|
3
3
|
// our own version). Each entity is ~/.open-claudia/entities/<slug>.md
|
|
4
|
-
// with frontmatter and
|
|
4
|
+
// with frontmatter and these sections:
|
|
5
5
|
// ## Notes — current truth about the entity (replaces wholesale)
|
|
6
6
|
// ## Log — dated one-line observations, newest last
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
7
|
+
// A person entity doubles as that person's PERSONA PACK — the structured
|
|
8
|
+
// per-person memory that shapes how the bot talks to them. Persona sections
|
|
9
|
+
// (written only when populated, so non-person entities stay unchanged):
|
|
10
|
+
// ## Role — what they do / own
|
|
11
|
+
// ## Style — how to communicate with them (tone, brevity, formality)
|
|
12
|
+
// ## Knows — domain knowledge to assume; what to explain vs skip
|
|
13
|
+
// ## Mandate — for non-owners: what the bot may do/discuss on their behalf
|
|
14
|
+
// plus a `relationship: owner|trusted|external` frontmatter scalar. The
|
|
15
|
+
// pre-turn router injects matched entities alongside context packs; the
|
|
16
|
+
// post-turn reviewer extracts/updates them after each turn; the dream
|
|
17
|
+
// compacts them.
|
|
9
18
|
|
|
10
19
|
const fs = require("fs");
|
|
11
20
|
const path = require("path");
|
|
@@ -18,6 +27,22 @@ const ENTITIES_DIR = process.env.ENTITIES_DIR ? path.resolve(process.env.ENTITIE
|
|
|
18
27
|
|
|
19
28
|
const TYPES = ["person", "place", "project", "org", "system", "thing"];
|
|
20
29
|
const MAX_LOG_ENTRIES = 40;
|
|
30
|
+
// Persona-pack sections layered on top of the base Notes/Log. Additive: only
|
|
31
|
+
// serialized when populated, so ordinary entities are untouched (R7).
|
|
32
|
+
const PERSONA_SECTIONS = ["Role", "Style", "Knows", "Mandate"];
|
|
33
|
+
const ALL_SECTIONS = ["Notes", "Log", ...PERSONA_SECTIONS];
|
|
34
|
+
const RELATIONSHIPS = ["owner", "trusted", "external"];
|
|
35
|
+
|
|
36
|
+
function emptySections() {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const s of ALL_SECTIONS) out[s] = "";
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeRelationship(rel) {
|
|
43
|
+
const r = String(rel || "").toLowerCase().trim();
|
|
44
|
+
return RELATIONSHIPS.includes(r) ? r : "";
|
|
45
|
+
}
|
|
21
46
|
|
|
22
47
|
function ensureDir() {
|
|
23
48
|
fs.mkdirSync(ENTITIES_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -45,28 +70,31 @@ function parseFrontmatter(content) {
|
|
|
45
70
|
}
|
|
46
71
|
|
|
47
72
|
function parseSections(body) {
|
|
48
|
-
const out =
|
|
73
|
+
const out = emptySections();
|
|
74
|
+
const headingRe = new RegExp(`^##\\s+(${ALL_SECTIONS.join("|")})\\s*$`, "i");
|
|
49
75
|
let current = null;
|
|
50
76
|
for (const line of String(body || "").split("\n")) {
|
|
51
|
-
const h = line.match(
|
|
77
|
+
const h = line.match(headingRe);
|
|
52
78
|
if (h) {
|
|
53
|
-
current = h[1].toLowerCase()
|
|
79
|
+
current = ALL_SECTIONS.find((s) => s.toLowerCase() === h[1].toLowerCase());
|
|
54
80
|
continue;
|
|
55
81
|
}
|
|
56
82
|
if (current) out[current] += line + "\n";
|
|
57
83
|
}
|
|
58
|
-
out
|
|
59
|
-
out.Log = out.Log.trim();
|
|
84
|
+
for (const s of ALL_SECTIONS) out[s] = out[s].trim();
|
|
60
85
|
return out;
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
function serialize(ent) {
|
|
64
|
-
|
|
89
|
+
const lines = [
|
|
65
90
|
"---",
|
|
66
91
|
`name: ${ent.name}`,
|
|
67
92
|
`type: ${ent.type}`,
|
|
68
93
|
`aliases: ${(ent.aliases || []).join(", ")}`,
|
|
69
94
|
`description: ${ent.description || ""}`,
|
|
95
|
+
];
|
|
96
|
+
if (ent.relationship) lines.push(`relationship: ${ent.relationship}`);
|
|
97
|
+
lines.push(
|
|
70
98
|
`created: ${ent.created || new Date().toISOString()}`,
|
|
71
99
|
`updated: ${ent.updated || new Date().toISOString()}`,
|
|
72
100
|
`last_seen: ${ent.last_seen || ""}`,
|
|
@@ -80,7 +108,14 @@ function serialize(ent) {
|
|
|
80
108
|
"",
|
|
81
109
|
(ent.sections?.Log || "").trim(),
|
|
82
110
|
"",
|
|
83
|
-
|
|
111
|
+
);
|
|
112
|
+
// Persona sections are written only when populated, so an ordinary entity
|
|
113
|
+
// (no persona data) serializes byte-identically to before the upgrade.
|
|
114
|
+
for (const s of PERSONA_SECTIONS) {
|
|
115
|
+
const val = (ent.sections?.[s] || "").trim();
|
|
116
|
+
if (val) lines.push(`## ${s}`, "", val, "");
|
|
117
|
+
}
|
|
118
|
+
return lines.join("\n");
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
function entityFile(slug) {
|
|
@@ -98,6 +133,7 @@ function readEntity(slug) {
|
|
|
98
133
|
type: normalizeType(fm.type),
|
|
99
134
|
aliases: (fm.aliases || "").split(",").map((a) => a.trim()).filter(Boolean),
|
|
100
135
|
description: fm.description || "",
|
|
136
|
+
relationship: normalizeRelationship(fm.relationship),
|
|
101
137
|
created: fm.created || "",
|
|
102
138
|
updated: fm.updated || "",
|
|
103
139
|
last_seen: fm.last_seen || "",
|
|
@@ -141,9 +177,11 @@ function today() {
|
|
|
141
177
|
return new Date().toISOString().slice(0, 10);
|
|
142
178
|
}
|
|
143
179
|
|
|
144
|
-
// Create-or-merge. Notes replace (current truth); log lines
|
|
145
|
-
// (capped); aliases union; description/type update when
|
|
146
|
-
|
|
180
|
+
// Create-or-merge. Notes + persona sections replace (current truth); log lines
|
|
181
|
+
// append (capped); aliases union; description/type/relationship update when
|
|
182
|
+
// supplied. Persona params (role/style/knows/mandate) each replace their
|
|
183
|
+
// section wholesale, mirroring Notes.
|
|
184
|
+
function upsertEntity({ name, type, aliases, description, notes, log, relationship, role, style, knows, mandate } = {}) {
|
|
147
185
|
const cleanName = String(name || "").trim();
|
|
148
186
|
if (!cleanName) throw new Error("entity needs a name");
|
|
149
187
|
const existing = findEntity(cleanName);
|
|
@@ -153,13 +191,18 @@ function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
|
|
|
153
191
|
type: normalizeType(type),
|
|
154
192
|
aliases: [],
|
|
155
193
|
description: "",
|
|
194
|
+
relationship: "",
|
|
156
195
|
created: new Date().toISOString(),
|
|
157
|
-
sections:
|
|
196
|
+
sections: emptySections(),
|
|
158
197
|
};
|
|
159
198
|
if (!ent.slug) throw new Error("entity needs a name");
|
|
199
|
+
// Existing entities read before this upgrade may lack persona keys; backfill.
|
|
200
|
+
ent.sections = { ...emptySections(), ...(ent.sections || {}) };
|
|
160
201
|
ent.updated = new Date().toISOString();
|
|
161
202
|
if (type) ent.type = normalizeType(type);
|
|
162
203
|
if (description) ent.description = String(description).trim();
|
|
204
|
+
const rel = normalizeRelationship(relationship);
|
|
205
|
+
if (rel) ent.relationship = rel;
|
|
163
206
|
for (const a of [].concat(aliases || [])) {
|
|
164
207
|
const alias = String(a).trim();
|
|
165
208
|
if (alias && alias.toLowerCase() !== ent.name.toLowerCase() &&
|
|
@@ -169,6 +212,10 @@ function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
|
|
|
169
212
|
}
|
|
170
213
|
ent.aliases = ent.aliases.slice(0, 8);
|
|
171
214
|
if (typeof notes === "string" && notes.trim()) ent.sections.Notes = notes.trim();
|
|
215
|
+
const persona = { Role: role, Style: style, Knows: knows, Mandate: mandate };
|
|
216
|
+
for (const [section, value] of Object.entries(persona)) {
|
|
217
|
+
if (typeof value === "string" && value.trim()) ent.sections[section] = value.trim();
|
|
218
|
+
}
|
|
172
219
|
if (typeof log === "string" && log.trim()) {
|
|
173
220
|
const entries = ent.sections.Log.split("\n").filter((l) => l.trim());
|
|
174
221
|
entries.push(`- [${today()}] ${log.trim().replace(/\n+/g, " ")}`);
|
|
@@ -262,7 +309,12 @@ function reindex() {
|
|
|
262
309
|
db.exec("DELETE FROM entities");
|
|
263
310
|
const insert = db.prepare("INSERT INTO entities (name, aliases, description, content, slug) VALUES (?, ?, ?, ?, ?)");
|
|
264
311
|
for (const e of all) {
|
|
265
|
-
|
|
312
|
+
// Index descriptive persona sections (Role/Style/Knows) so a persona pack
|
|
313
|
+
// is matchable by its content. Mandate is policy, not description — leaving
|
|
314
|
+
// it out keeps guardrail text from surfacing packs on unrelated mentions.
|
|
315
|
+
const content = [e.sections.Notes, e.sections.Log, e.sections.Role, e.sections.Style, e.sections.Knows]
|
|
316
|
+
.filter(Boolean).join("\n");
|
|
317
|
+
insert.run(e.name, e.aliases.join(" "), e.description, content, e.slug);
|
|
266
318
|
}
|
|
267
319
|
db.exec("COMMIT");
|
|
268
320
|
} catch (e) {
|
|
@@ -326,6 +378,7 @@ function matchEntities(text, { limit = 4, threshold = null } = {}) {
|
|
|
326
378
|
|
|
327
379
|
module.exports = {
|
|
328
380
|
ENTITIES_DIR, TYPES, slugify,
|
|
381
|
+
PERSONA_SECTIONS, RELATIONSHIPS, normalizeRelationship,
|
|
329
382
|
listEntities, findEntity, readEntity, writeEntity, upsertEntity, removeEntity,
|
|
330
383
|
touchSeen, entityNameFromPath, matchEntities, reindex, markIndexDirty,
|
|
331
384
|
};
|
package/core/pack-review.js
CHANGED
|
@@ -114,6 +114,13 @@ An entity note is a short memory file about ONE specific named entity — a pers
|
|
|
114
114
|
- Notes: current truth about the entity (who/what it is, role, preferences, relationships). Replaces wholesale.
|
|
115
115
|
- Log: one-line dated observations, appended.
|
|
116
116
|
|
|
117
|
+
When the entity is a PERSON, the note doubles as their PERSONA PACK — the memory that shapes how the assistant talks to them. A person may also carry (all optional, all replace-wholesale like Notes):
|
|
118
|
+
- relationship: one of owner | trusted | external. The person the assistant works for is "owner"; a colleague the owner trusts is "trusted"; anyone else is "external". Only set this when the turn makes it clear.
|
|
119
|
+
- role: what they do / own, in a line.
|
|
120
|
+
- style: how to communicate with them — tone, brevity, formality, format preferences.
|
|
121
|
+
- knows: domain knowledge to assume they have, so the assistant knows what to explain vs skip.
|
|
122
|
+
- mandate: for NON-owners only, what the assistant may do or discuss on their behalf (a scope/guardrail). Leave empty for the owner.
|
|
123
|
+
|
|
117
124
|
An ABILITY is a special kind of pack (kind:"ability") for a REUSABLE HOW-TO that is NOT tied to one project — a procedure, pattern, or technique you would follow just as well on a different project later (e.g. "ship a mobile app: bump versionCode, build the APK, push the in-app updater"; "safely run a destructive DB write"; "wire up a new ArgoCD app"). A normal pack (kind:"context", the default) is about ONE project/system and stays scoped to it. Decide by NATURE, not by repetition: if THIS turn demonstrated a self-contained method you would re-run on a DIFFERENT project, capture it as an ability the FIRST time you see it — do not wait for it to recur. Give an ability an ACTIVITY-oriented name, description, and tags (what you DO — the verbs, tools, and artifacts involved) so it can be found later from a different project by the work being done, not by a project name. Set "learned_on" to the project pack dir this turn worked on and list that same dir in "applied_on". If an ability below (marked ◆ability) already covers the method, do NOT duplicate it — UPDATE it and add the current project dir to "applied_on" so it visibly transfers.
|
|
118
125
|
|
|
119
126
|
A LESSON is a single always-loaded rule (NOT topic-gated like packs/entities — it loads on every turn). Lessons exist for ONE purpose: to stop a recurring mistake. Propose a lesson ONLY when THIS turn shows the assistant MISSED something it should already have known — i.e. the user corrected the assistant ("no", "that's wrong", "actually it's X"), or signalled repetition ("again", "I keep telling you", "as I said", "I've told you before", "like I mentioned"). That friction is the proof that topic-matched memory failed, so the corrected fact must move to the always-on tier. No correction/repetition signal in the turn → NO lesson (return an empty lessons array). A lesson is the CORRECT fact written as one crisp imperative line, cross-cutting and durable (a mechanism, rule, or constraint that will matter on future, possibly off-topic turns) — never a one-off task detail or a fact with no miss behind it. Point "src" at the pack that should hold the full context if one fits. If an existing lesson below already covers it, reuse its EXACT wording so it reinforces rather than duplicating.
|
|
@@ -153,6 +160,7 @@ Decide. Rules:
|
|
|
153
160
|
- State should be concise (under 150 words). Journal entries one sentence. Never start a journal or log sentence with a date — dates are prepended automatically.
|
|
154
161
|
- At most ${MAX_ACTIONS} pack actions.
|
|
155
162
|
- Entities: add an item when the turn revealed something durable about a specific named person, place, project, org, or system — their role, status, preferences, or relationship to other work. Use the existing entity name when one matches (check aliases). Skip entities mentioned only in passing with nothing learned. Notes under 100 words; "notes" null means leave Notes unchanged. At most ${MAX_ENTITY_ACTIONS} entity items.
|
|
163
|
+
- PERSONA (people only): when the turn revealed how to talk to a person or what they know, populate the persona fields — role/style/knows, and for non-owners a mandate (scope). Set relationship (owner|trusted|external) only when the turn makes it clear. Each persona field replaces its section wholesale; send only the ones this turn actually informed, null/omit the rest. Do NOT invent a mandate for the owner. Never copy a guardrail the owner set for one person onto another.
|
|
156
164
|
- Packs and entities are independent — a turn can update both, either, or neither. Do not duplicate the same fact in a pack AND an entity unless it genuinely belongs to both.
|
|
157
165
|
- Lessons are the rare case: at most ${MAX_LESSON_ACTIONS} per turn, and ONLY on a real correction/repetition signal as defined above. The default is an empty lessons array. When you do promote one, ALSO record the same fact in the appropriate pack (update/create) so the durable home stays authoritative — the lesson is just the always-on shortcut. Never put secrets in a lesson.
|
|
158
166
|
|
|
@@ -162,7 +170,7 @@ Reply with ONLY a JSON object, no prose, no code fences:
|
|
|
162
170
|
| {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "kind": "context|ability", "learned_on": "<originating project dir — abilities only>", "applied_on": ["<project dirs — abilities only>"], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>"}
|
|
163
171
|
],
|
|
164
172
|
"entities": [
|
|
165
|
-
{"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>"}
|
|
173
|
+
{"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>", "relationship": "<owner|trusted|external, people only, only when clear, else null>", "role": "<people only, or null>", "style": "<people only: how to talk to them, or null>", "knows": "<people only: what to assume they know, or null>", "mandate": "<non-owner people only: what you may do/discuss on their behalf, or null>"}
|
|
166
174
|
],
|
|
167
175
|
"lessons": [
|
|
168
176
|
{"text": "<the correct fact as one crisp imperative line>", "src": "<pack dir that holds the full context, or empty>", "trigger": "<the exact correction/repetition phrase from the turn>"}
|
|
@@ -320,6 +328,13 @@ function applyEntityAction(e) {
|
|
|
320
328
|
description: e.description,
|
|
321
329
|
notes: typeof e.notes === "string" ? e.notes : "",
|
|
322
330
|
log: e.log || "",
|
|
331
|
+
// Persona-pack fields (people only). Each replaces its section wholesale
|
|
332
|
+
// when a non-empty string is supplied; null/absent leaves it untouched.
|
|
333
|
+
relationship: typeof e.relationship === "string" ? e.relationship : "",
|
|
334
|
+
role: typeof e.role === "string" ? e.role : "",
|
|
335
|
+
style: typeof e.style === "string" ? e.style : "",
|
|
336
|
+
knows: typeof e.knows === "string" ? e.knows : "",
|
|
337
|
+
mandate: typeof e.mandate === "string" ? e.mandate : "",
|
|
323
338
|
});
|
|
324
339
|
return { kind: created ? "create" : "update", slug: entity.slug, name: entity.name, type: entity.type, note: e.log || e.description || "" };
|
|
325
340
|
}
|
|
@@ -368,7 +383,17 @@ function shouldSkipReview({ userText, assistantText, signals = {} }) {
|
|
|
368
383
|
|
|
369
384
|
// Fire-and-forget. `announce` is an async (text) => void bound to the
|
|
370
385
|
// originating channel; failures are logged, never thrown into the turn.
|
|
371
|
-
|
|
386
|
+
// Provenance (2.5): stamp appended Log/Journal lines with the contributing
|
|
387
|
+
// person when they're NOT the owner. The owner is the default author, so
|
|
388
|
+
// tagging their contributions would be pure noise — the signal is precisely
|
|
389
|
+
// "this note came from someone else" (an external/trusted colleague), which the
|
|
390
|
+
// enforcer and the owner both want visible in the topic's history.
|
|
391
|
+
function provenanceTag(source) {
|
|
392
|
+
if (!source || source.isOwner || !String(source.name || "").trim()) return "";
|
|
393
|
+
return `(via ${String(source.name).trim()}) `;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks, source }) {
|
|
372
397
|
if (!enabled()) return;
|
|
373
398
|
const skip = shouldSkipReview({ userText, assistantText, signals });
|
|
374
399
|
if (skip) {
|
|
@@ -391,8 +416,10 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
|
|
|
391
416
|
if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
|
|
392
417
|
const lines = [];
|
|
393
418
|
const usedIds = []; // nodes this turn's work demonstrably touched
|
|
419
|
+
const tag = provenanceTag(source);
|
|
394
420
|
for (const a of decision.actions) {
|
|
395
421
|
try {
|
|
422
|
+
if (tag && typeof a.journal === "string" && a.journal.trim()) a.journal = tag + a.journal.trim();
|
|
396
423
|
const r = applyAction(a, activeSet);
|
|
397
424
|
if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
|
|
398
425
|
if (r && r.kind === "skipped") {
|
|
@@ -414,6 +441,7 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
|
|
|
414
441
|
}
|
|
415
442
|
for (const ea of decision.entities) {
|
|
416
443
|
try {
|
|
444
|
+
if (tag && typeof ea.log === "string" && ea.log.trim()) ea.log = tag + ea.log.trim();
|
|
417
445
|
const r = applyEntityAction(ea);
|
|
418
446
|
if (r) {
|
|
419
447
|
if (r.slug) usedIds.push(`entity:${r.slug}`);
|
|
@@ -461,4 +489,4 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
|
|
|
461
489
|
});
|
|
462
490
|
}
|
|
463
491
|
|
|
464
|
-
module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
|
|
492
|
+
module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, provenanceTag, ENTITY_EMOJI };
|
package/core/people.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const fs = require("fs");
|
|
12
12
|
const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
|
|
13
13
|
const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
|
|
14
|
+
const { slugify } = require("./entities");
|
|
14
15
|
|
|
15
16
|
function nowIso() { return new Date().toISOString(); }
|
|
16
17
|
|
|
@@ -66,6 +67,7 @@ function add({ name, isOwner = false, bio = null }) {
|
|
|
66
67
|
handles: [],
|
|
67
68
|
notes: [],
|
|
68
69
|
primaryChannel: null,
|
|
70
|
+
entitySlug: null,
|
|
69
71
|
createdAt: nowIso(),
|
|
70
72
|
updatedAt: nowIso(),
|
|
71
73
|
};
|
|
@@ -195,6 +197,26 @@ function removeNote(personId, index) {
|
|
|
195
197
|
return removed;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
// Bind a person to their persona pack (entities/<slug>.md). Explicit link so a
|
|
201
|
+
// person can point at a differently-named pack; setEntitySlug(id, null) clears.
|
|
202
|
+
function setEntitySlug(personId, slug) {
|
|
203
|
+
const all = load();
|
|
204
|
+
const p = all.find((x) => x.id === personId);
|
|
205
|
+
if (!p) return null;
|
|
206
|
+
p.entitySlug = slug ? slugify(slug) : null;
|
|
207
|
+
p.updatedAt = nowIso();
|
|
208
|
+
save(all);
|
|
209
|
+
return p;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Which persona pack belongs to this person: the explicit pointer if set,
|
|
213
|
+
// otherwise the slug derived from their name. Pure — does not read the pack.
|
|
214
|
+
function resolveEntitySlug(person) {
|
|
215
|
+
if (!person) return null;
|
|
216
|
+
if (person.entitySlug) return person.entitySlug;
|
|
217
|
+
return person.name ? slugify(person.name) : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
function owners() { return load().filter((p) => p.isOwner); }
|
|
199
221
|
|
|
200
222
|
function hasOwnerRecord() { return owners().length > 0; }
|
|
@@ -233,6 +255,7 @@ function roster() {
|
|
|
233
255
|
bio: p.bio || null,
|
|
234
256
|
handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
|
|
235
257
|
primaryChannel: p.primaryChannel || null,
|
|
258
|
+
entitySlug: resolveEntitySlug(p),
|
|
236
259
|
notes: (p.notes || []).slice(-5),
|
|
237
260
|
}));
|
|
238
261
|
}
|
|
@@ -243,5 +266,6 @@ module.exports = {
|
|
|
243
266
|
findById, findByName, findByCanonicalUserId, findByHandle,
|
|
244
267
|
linkHandle, unlinkHandle, setPrimary,
|
|
245
268
|
note, removeNote,
|
|
269
|
+
setEntitySlug, resolveEntitySlug,
|
|
246
270
|
owners, hasOwnerRecord, seedOwnerFromLegacy,
|
|
247
271
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Pure helpers for origin-aware queue draining. Kept separate from the runner
|
|
2
|
+
// so the grouping logic — which decides where a drained reply is routed under
|
|
3
|
+
// unified identity — is unit-testable without spawning a run.
|
|
4
|
+
|
|
5
|
+
// A queued message's origin channel, or a sentinel for legacy items that were
|
|
6
|
+
// queued before origin capture existed (those fall back to the finishing turn's
|
|
7
|
+
// context, exactly as before).
|
|
8
|
+
function originKey(item) {
|
|
9
|
+
return item && item.origin ? `${item.origin.transport}:${item.origin.channelId}` : "__current__";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Split the leading run of same-origin messages off the front of the queue.
|
|
13
|
+
// Returns { group, rest }: `group` is the contiguous same-origin prefix (to run
|
|
14
|
+
// now, scoped to that origin), `rest` is everything after it (to be requeued so
|
|
15
|
+
// the natural close→drain tail-recursion handles the next origin group). For a
|
|
16
|
+
// non-empty queue, `group` always has at least one item.
|
|
17
|
+
function splitLeadingOriginGroup(queue) {
|
|
18
|
+
if (!Array.isArray(queue) || queue.length === 0) return { group: [], rest: [] };
|
|
19
|
+
const firstKey = originKey(queue[0]);
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < queue.length && originKey(queue[i]) === firstKey) i++;
|
|
22
|
+
return { group: queue.slice(0, i), rest: queue.slice(i) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { originKey, splitLeadingOriginGroup };
|
|
@@ -100,6 +100,26 @@ const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLo
|
|
|
100
100
|
// measured as THE dominant recall latency (p50 ~13.5s even warm).
|
|
101
101
|
const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
|
|
102
102
|
const EXCERPT_CHARS = 600;
|
|
103
|
+
|
|
104
|
+
// Relationship gate (R6): episodic recall reaches ACROSS conversations into the
|
|
105
|
+
// owner's project transcripts, which can hold private context. Surfacing that to
|
|
106
|
+
// a non-owner speaker is a cross-tenant leak, so cross-conversation episodes are
|
|
107
|
+
// OWNER-ONLY. Fail-closed: an unknown speaker or any resolution error suppresses
|
|
108
|
+
// episodes; only a positively-identified owner (or no speaker context at all,
|
|
109
|
+
// e.g. CLI/tests) may pull them. Same-conversation packs/entities are unaffected.
|
|
110
|
+
function episodesAllowedForSpeaker() {
|
|
111
|
+
try {
|
|
112
|
+
const { currentAdapter, currentChannelId } = require("../context");
|
|
113
|
+
const adapter = currentAdapter();
|
|
114
|
+
const channelId = currentChannelId();
|
|
115
|
+
if (!adapter || !channelId) return true; // no speaker context (CLI/tests) → ungated
|
|
116
|
+
const people = require("../people");
|
|
117
|
+
const speaker = people.findByHandle(adapter.type, channelId);
|
|
118
|
+
return !!(speaker && speaker.isOwner); // unknown/non-owner → gated
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return false; // resolution error → fail closed
|
|
121
|
+
}
|
|
122
|
+
}
|
|
103
123
|
const CONTEXT_CLIP = 3000;
|
|
104
124
|
// Fraction of cascade auto-verdicts also sent to the judge as an accuracy
|
|
105
125
|
// audit — divergence is logged and the dream disables the cascade if it drifts.
|
|
@@ -387,7 +407,7 @@ async function run(ctx) {
|
|
|
387
407
|
// knowledge (packs) to episodes (what we actually did last time) — kept ones
|
|
388
408
|
// inject as a snippet plus a pointer to reopen the conversation window.
|
|
389
409
|
let episodeCands = [];
|
|
390
|
-
if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
|
|
410
|
+
if (tier === "full" && EPISODES_ENABLED && episodesAllowedForSpeaker() && transcriptIndex && transcriptIndex.available()) {
|
|
391
411
|
try {
|
|
392
412
|
const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
|
|
393
413
|
episodeCands = (hits || []).map((h) => {
|
|
@@ -582,4 +602,4 @@ async function run(ctx) {
|
|
|
582
602
|
};
|
|
583
603
|
}
|
|
584
604
|
|
|
585
|
-
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps };
|
|
605
|
+
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
|
package/core/router.js
CHANGED
|
@@ -26,11 +26,14 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
|
|
|
26
26
|
const channelWizard = require("./channel-wizard");
|
|
27
27
|
require("./handlers"); // side-effect: register slash commands
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// Cross-channel dedup. Message ids are unique per adapter, not globally, so
|
|
30
|
+
// the adapter stays in the key. Keying by canonical (not raw channelId) means
|
|
31
|
+
// a message redelivered against a different channel that resolves to the same
|
|
32
|
+
// person is still caught once the owner's channels are unified.
|
|
31
33
|
const processedMessages = new Set();
|
|
32
34
|
function isDuplicate(envelope) {
|
|
33
|
-
const
|
|
35
|
+
const canonical = envelope.canonicalUserId || envelope.channelId;
|
|
36
|
+
const key = `${canonical}:${envelope.adapter?.id || envelope.adapter?.type}:${envelope.messageId}`;
|
|
34
37
|
if (processedMessages.has(key)) return true;
|
|
35
38
|
processedMessages.add(key);
|
|
36
39
|
if (processedMessages.size > 400) {
|
package/core/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
} = require("./config");
|
|
14
14
|
const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
|
|
15
15
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
16
|
+
const { splitLeadingOriginGroup } = require("./queue-drain");
|
|
16
17
|
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
17
18
|
const { redactSensitive } = require("./redact");
|
|
18
19
|
const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
|
|
@@ -782,7 +783,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
782
783
|
cwd = resolveRunCwd(cwd);
|
|
783
784
|
|
|
784
785
|
if (state.runningProcess) {
|
|
785
|
-
|
|
786
|
+
// Capture the origin channel so the drained reply routes back to where the
|
|
787
|
+
// message came from. Under unified identity the owner's channels share one
|
|
788
|
+
// run-lock and queue, so a Kazee message queued during a Telegram run must
|
|
789
|
+
// still be answered on Kazee — not wherever the finishing turn happens to be.
|
|
790
|
+
state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now(), origin: store ? { ...store } : null });
|
|
786
791
|
await send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId });
|
|
787
792
|
return;
|
|
788
793
|
}
|
|
@@ -1132,10 +1137,19 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1132
1137
|
};
|
|
1133
1138
|
|
|
1134
1139
|
const drainQueuedMessages = async () => {
|
|
1135
|
-
if (state.messageQueue.length
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1140
|
+
if (state.messageQueue.length === 0 || !state.currentSession) return;
|
|
1141
|
+
const drained = state.messageQueue.splice(0);
|
|
1142
|
+
// Handle one same-origin run at a time; requeue the remainder so the
|
|
1143
|
+
// finishing turn's close→drain tail-recursion picks up the next origin
|
|
1144
|
+
// group. Each group's reply is scoped to the channel it came from, so a
|
|
1145
|
+
// message queued from one of the owner's channels during a run on another
|
|
1146
|
+
// is still answered on its own channel (unified identity, one run-lock).
|
|
1147
|
+
const { group, rest } = splitLeadingOriginGroup(drained);
|
|
1148
|
+
if (rest.length) state.messageQueue.unshift(...rest);
|
|
1149
|
+
|
|
1150
|
+
const runGroup = async () => {
|
|
1151
|
+
if (group.length === 1) {
|
|
1152
|
+
const only = group[0];
|
|
1139
1153
|
await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
|
|
1140
1154
|
} else {
|
|
1141
1155
|
const fmtTime = (ts) => {
|
|
@@ -1145,16 +1159,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1145
1159
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
1146
1160
|
return `${hh}:${mm}:${ss}`;
|
|
1147
1161
|
};
|
|
1148
|
-
const numbered =
|
|
1162
|
+
const numbered = group.map((m, idx) => `[${idx + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
|
|
1149
1163
|
const batched =
|
|
1150
|
-
`While you were working the user sent these ${
|
|
1164
|
+
`While you were working the user sent these ${group.length} follow-up messages (oldest first):\n\n` +
|
|
1151
1165
|
`${numbered}\n\n` +
|
|
1152
1166
|
`Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
|
|
1153
1167
|
`if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
|
|
1154
|
-
const last =
|
|
1168
|
+
const last = group[group.length - 1];
|
|
1155
1169
|
await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
|
|
1156
1170
|
}
|
|
1157
|
-
}
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
const origin = group[0].origin;
|
|
1174
|
+
if (origin) await chatContext.run(origin, runGroup);
|
|
1175
|
+
else await runGroup();
|
|
1158
1176
|
};
|
|
1159
1177
|
|
|
1160
1178
|
const updateProgress = async () => {
|
|
@@ -1580,6 +1598,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1580
1598
|
...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
|
|
1581
1599
|
])],
|
|
1582
1600
|
signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
|
|
1601
|
+
// Provenance (2.5): who contributed this turn, so accreted Log/Journal
|
|
1602
|
+
// lines from a non-owner get stamped "(via <name>)". Resolved from the
|
|
1603
|
+
// live store here because the reviewer applies in an async continuation.
|
|
1604
|
+
source: (() => {
|
|
1605
|
+
try {
|
|
1606
|
+
const sp = require("./people").findByHandle(store.adapter?.type, store.channelId);
|
|
1607
|
+
if (sp) return { name: sp.name, isOwner: !!sp.isOwner };
|
|
1608
|
+
} catch (e) {}
|
|
1609
|
+
return null;
|
|
1610
|
+
})(),
|
|
1583
1611
|
announce: (text) => chatContext.run(store, () => send(text)),
|
|
1584
1612
|
});
|
|
1585
1613
|
}
|