Skip to content
Closed
163 changes: 141 additions & 22 deletions dist/index.js

Large diffs are not rendered by default.

285 changes: 284 additions & 1 deletion dist/src/auto-capture-cleanup.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,296 @@ export function stripAutoCaptureInjectedPrefix(role, text) {
normalized = normalized.replace(/\n{3,}/g, "\n\n");
return normalized.trim();
}
/**
* Message-tool channels (Slack groups and other non-auto-delivery runs) hand
* the plugin a "user" message that concatenates runtime scaffolding around the
* real inbound content: a Delivery banner first, then optionally a quoted
* re-render of channel history the session has already seen. Both are
* host-emitted grammar, matched exactly and stripped fail-closed — unmatched
* lines always pass through. Full group-channel support (per-sender speaker
* awareness) is the permanent design; this keeps the transcript clean until
* that lands.
*/
export const MESSAGE_TOOL_DELIVERY_BANNER_PREFIX = "Delivery: Final assistant text is not automatically delivered in this run.";
const CHAT_HISTORY_QUOTE_HEADERS = [
"Chat history since last reply (untrusted, for context):",
"Conversation context (untrusted, chronological, selected for current message):",
];
const QUOTED_HISTORY_LINE = /^#\d+(?:\.\d+)? \S+ \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \S+ [^:]+: /;
export function stripGroupChannelScaffold(text) {
const lines = text.split("\n");
const kept = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (line.startsWith(MESSAGE_TOOL_DELIVERY_BANNER_PREFIX)) {
index++;
continue;
}
if (CHAT_HISTORY_QUOTE_HEADERS.includes(line.trim())) {
index++;
while (index < lines.length && (QUOTED_HISTORY_LINE.test(lines[index]) || lines[index].trim() === "")) {
index++;
}
continue;
}
kept.push(line);
index++;
}
return kept.join("\n").trim();
}
/**
* Channel-agnostic injection slicing: the ingress hook delivers each inbound
* message RAW, before the host composes the prompt and layers channel
* injections above it, and the raw message always sits at the absolute
* bottom of the composed text. Anchoring on a recently seen raw text slices
* every injection shape — including channels whose grammar we have never
* seen — without parsing any of them. The newline boundary requirement stops
* mid-line false matches (a raw that merely ends the same as real content),
* the minimum length keeps trivial raws ("ok") from slicing multi-line
* messages, and no match leaves the text untouched (the deterministic
* header strips remain the fallback).
*/
export const RAW_INGRESS_ANCHOR_MIN_LENGTH = 12;
export function anchorTextToRawIngress(text, recentRaws) {
let best = null;
for (const raw of recentRaws) {
if (!raw || raw.length < RAW_INGRESS_ANCHOR_MIN_LENGTH)
continue;
if (text === raw)
return text;
if (text.endsWith("\n" + raw) && (best === null || raw.length > best.length)) {
best = raw;
}
}
return best ?? text;
}
export function normalizeAutoCaptureText(role, text, shouldSkipMessage) {
if (typeof role !== "string")
return null;
const normalized = stripAutoCaptureInjectedPrefix(role, text);
const descaffolded = role === "user" ? stripGroupChannelScaffold(text) : text;
if (!descaffolded)
return null;
const normalized = stripAutoCaptureInjectedPrefix(role, descaffolded);
if (!normalized)
return null;
if (shouldSkipMessage?.(role, normalized))
return null;
return normalized;
}
/**
* Direct (1:1) conversations by session-key grammar, allowlisted from the
* host's key builder: the dmScope-collapsed main key, dashboard/webchat
* sessions, and the explicit `:direct:` peer forms. Everything else —
* channels, groups, threads, topics, and any key shape we have never seen —
* is treated as a group chat, fail-closed. The context window falls back to
* contextTurns=0 there (original captureAssistant-gated behavior); full
* group-channel support arrives with per-sender speaker awareness.
*/
export function isDirectConversationSessionKey(sessionKey) {
if (typeof sessionKey !== "string" || sessionKey.length === 0)
return false;
const key = sessionKey.toLowerCase();
if (/^agent:[^:]+:main$/.test(key))
return true;
if (/^agent:[^:]+:dashboard(?::|$)/.test(key))
return true;
if (key.includes(":direct:"))
return true;
return false;
}
/**
* A literal speaker tag typed INSIDE a message could fake a block boundary
* (or defeat tag-boundary trimming, which trusts that literal tags only occur
* as real boundaries). Rewritten with guillemets the text stays readable but
* can no longer be confused with transcript structure.
*/
export function neutralizeSpeakerTagSpoof(text) {
return text.replace(/<(\/?)((?:context_)?(?:user|assistant)_message)>/g, "‹$1$2›");
}
/**
* Renders turns oldest-first with each message wholly enclosed in
* <user_message>/<assistant_message> tags. Line prefixes ("User:") mark only
* the first line of a message, so a multi-paragraph assistant reply sheds its
* speaker after the first paragraph and the extractor misattributes the rest
* to the user; whole-message tags give every line an unambiguous owner. The
* `_userLabel` parameter is kept for call-site compatibility -- the user's
* display name travels in the prompt header, not per turn.
*/
export function formatConversationTranscript(turns, _userLabel = "User", options = {}) {
return turns
.map((turn) => {
const tag = turn.role === "user"
? turn.context
? "context_user_message"
: "user_message"
: turn.context || options.assistantContextOnly === true
? "context_assistant_message"
: "assistant_message";
return `<${tag}>\n${neutralizeSpeakerTagSpoof(turn.text)}\n</${tag}>`;
})
.join("\n");
}
/**
* Bounds a tag-wrapped transcript to `maxChars` by keeping the tail and then
* snapping the cut to the next opening tag, so the prompt never leads with a
* headless half message whose speaker was sliced away.
*/
export function trimTranscriptToTagBoundary(transcript, maxChars) {
if (transcript.length <= maxChars) {
return transcript;
}
const sliced = transcript.slice(-maxChars);
const tagStarts = ["<user_message>", "<assistant_message>", "<context_user_message>", "<context_assistant_message>"]
.map((tag) => sliced.indexOf(tag))
.filter((index) => index >= 0);
if (tagStarts.length === 0) {
return sliced;
}
return sliced.slice(Math.min(...tagStarts));
}
/**
* Assembles the ordered turn sequence for the extraction prompt's transcript
* from this call's true message-loop order, without recomputing any
* eligibility or watermark decision -- it only consumes their already-decided
* results.
* - `newUserTexts` narrower than `eligibleTexts` (watermark tail-slice): skip
* the already-extracted prefix. The eligibility loop pushes exactly one
* turn per eligible text, so when the counts line up the skip is a plain
* index slice -- deliberately role-agnostic, because under
* captureAssistant=true eligible texts are mixed-role and a user-turn
* counting walk over-skips (it consumes one USER turn per already-seen
* text of ANY role, emptying the transcript).
* - Counts misaligned (defensive): fall back to the role-aware walk that
* drops one leading user turn per already-seen text, along with the
* assistant replies of the dropped pairs.
* - `newUserTexts` not a tail-slice of `eligibleTexts` at all (pending-ingress
* replay from a different source, no per-message role correlation
* available): fall back to flat user turns for the replayed content.
*/
export function buildConversationTurnsForExtraction(params) {
const { messageLoopTurns, eligibleTexts, newUserTexts } = params;
const isTailSliceOfEligible = newUserTexts.length <= eligibleTexts.length &&
eligibleTexts
.slice(eligibleTexts.length - newUserTexts.length)
.every((text, i) => text === newUserTexts[i]);
if (!isTailSliceOfEligible) {
return newUserTexts.map((text) => ({ role: "user", text }));
}
if (messageLoopTurns.length === eligibleTexts.length) {
return messageLoopTurns.slice(eligibleTexts.length - newUserTexts.length);
}
const skipUserCount = eligibleTexts.length - newUserTexts.length;
const thisCallTurns = [];
let userSeen = 0;
for (const turn of messageLoopTurns) {
if (turn.role === "user") {
userSeen++;
if (userSeen <= skipUserCount)
continue;
}
else if (userSeen <= skipUserCount) {
// Reply to a dropped (already-extracted) user turn: goes with its pair.
continue;
}
thisCallTurns.push(turn);
}
return thisCallTurns;
}
/**
* Bounds a rolling pair window to at most `maxUserTurns` user turns, keeping
* the newest ones with their interleaved assistant replies, and never leaving
* an orphan assistant turn ahead of the window's first user turn. The caller
* passes max(autoCaptureContextTurns, this call's new user turns), so the
* transcript always contains every not-yet-extracted user turn, padded with
* earlier still-buffered pairs up to the configured window.
*/
export function trimTurnsToUserCap(turns, maxUserTurns) {
const cap = Math.max(1, maxUserTurns);
let userCount = 0;
let start = turns.length;
for (let i = turns.length - 1; i >= 0; i--) {
if (turns[i].role === "user") {
userCount++;
if (userCount > cap)
break;
start = i;
}
}
if (userCount === 0) {
// All-assistant window (possible under captureAssistant=true when the
// delta carries only assistant turns): no user anchor exists, so keep
// the newest `cap` turns instead of silently dropping everything.
return turns.slice(-cap);
}
return turns.slice(start);
}
/**
* Repairs a pair window that double-preserved deferred turns. A below-threshold
* deferral keeps content alive on two independent paths -- the rolling pair
* buffer, and the watermark rollback (or pending-ingress re-queue) whose next
* slice re-includes the same turns -- so the assembled window can carry the
* same exchange twice. Collapse duplicates by user text at pair granularity:
* a pair-shaped copy (user turn plus its replies) beats a flat re-queued copy,
* copies of an identical exchange collapse to the latest, and a repeated user
* text whose replies differ is a real conversation and is kept whole.
*/
export function dedupePairWindow(turns) {
const groups = [];
let current = null;
for (const turn of turns) {
if (turn.role === "user") {
current = { turns: [turn], userText: turn.text, replies: "" };
groups.push(current);
}
else if (current) {
current.turns.push(turn);
current.replies = JSON.stringify(current.turns.slice(1).map((t) => t.text));
}
else {
groups.push({ turns: [turn], userText: null, replies: "" });
}
}
const kept = [];
for (const group of groups) {
if (group.userText === null) {
kept.push(group);
continue;
}
let prevIndex = -1;
for (let i = kept.length - 1; i >= 0; i--) {
if (kept[i].userText === group.userText) {
prevIndex = i;
break;
}
}
if (prevIndex < 0) {
kept.push(group);
continue;
}
const prev = kept[prevIndex];
const prevPaired = prev.turns.length > 1;
const currPaired = group.turns.length > 1;
if (currPaired && prevPaired) {
if (prev.replies === group.replies) {
kept.splice(prevIndex, 1);
kept.push(group);
}
else {
kept.push(group);
}
}
else if (currPaired && !prevPaired) {
kept.splice(prevIndex, 1);
kept.push(group);
}
else if (!currPaired && prevPaired) {
continue;
}
else {
kept.splice(prevIndex, 1);
kept.push(group);
}
}
return kept.flatMap((group) => group.turns);
}
70 changes: 60 additions & 10 deletions dist/src/extraction-prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,54 @@
* - buildExtractionPrompt: 6-category L0/L1/L2 extraction with few-shot
* - buildDedupPrompt: CREATE/MERGE/SKIP dedup decision
* - buildMergePrompt: Memory merge with three-level structure
*
* buildExtractionPrompt returns a {system, user} pair: instructions,
* criteria, and the output-format contract live in `system`; the per-call
* conversation transcript lives in `user`.
*/
export function buildExtractionPrompt(conversationText, user) {
return `Analyze the following session context and extract memories worth long-term preservation.

User: ${user}

Target Output Language: auto (detect from recent messages)

## Recent Conversation
${conversationText}
export function buildExtractionPrompt(conversationText, user, options = {}) {
// Transcript modes, driven by captureAssistant x autoCaptureContextTurns:
// - assistantEligible (captureAssistant=true): assistant blocks appear AND are
// valid grounding sources, with attribution rules.
// - contextWindow (autoCaptureContextTurns > 0): already-processed turns ride
// along under context_user_message / context_assistant_message tags —
// context only, never sources. With captureAssistant=false every assistant
// turn is context (self messages are never sources).
// - neither: assistant lines are excluded from the transcript entirely, so
// the prompt does not describe assistant blocks at all.
const assistantEligible = options.assistantEligible === true;
const contextWindow = options.contextWindow === true;
const assistantContext = !assistantEligible && contextWindow;
const assistantFormatBullet = assistantEligible
? `
- <assistant_message>...</assistant_message> wraps ONE message written by the AI assistant.`
: assistantContext
? `
- <context_assistant_message>...</context_assistant_message> wraps ONE message written by the AI assistant. Context only — use it to resolve what the user meant (pronouns, follow-ups, corrections); it is NEVER a source of memories.`
: "";
const contextUserBullet = contextWindow
? `
- <context_user_message>...</context_user_message> wraps a user message that was ALREADY processed by a previous extraction run. Context only — do not extract it again.`
: "";
const contextAssistantEligibleBullet = contextWindow && assistantEligible
? `
- <context_assistant_message>...</context_assistant_message> wraps an assistant message that was ALREADY processed by a previous extraction run. Context only.`
: "";
const userGroundingSuffix = assistantEligible ? "" : " Memories may only be grounded here.";
const assistantBlocksRule = assistantEligible
? `
- <assistant_message> blocks: also valid sources — but only for concrete facts the user did not correct. Skip the assistant's greetings, guesses, and self-description.
- Attribute every memory to whoever actually said it. When both said it, use the <user_message> version.${contextWindow ? `
- <context_user_message> and <context_assistant_message> blocks: already processed in previous runs — NEVER extract memories from them again.` : ""}`
: assistantContext
? `
- <context_user_message> and <context_assistant_message> blocks: context only — NEVER extract memories from them. A fact that appears only in a context block must not be stored.`
: "";
const system = `You are a memory extraction agent. Analyze session context and extract memories worth long-term preservation.

## Transcript format
The conversation is a sequence of tagged blocks in chronological order:
- <user_message>...</user_message> wraps ONE${contextWindow ? " NEW" : ""} message written by the human user.${userGroundingSuffix}${assistantFormatBullet}${contextUserBullet}${contextAssistantEligibleBullet}

# Memory Extraction Criteria

Expand All @@ -33,7 +71,7 @@ ${conversationText}
- Degraded or incomplete references: If the user mentions something vaguely ("that thing I said"), do NOT invent details or create a hollow memory
- Raw conversation carryover: quoted or attributed transcript blocks, especially 3+ lines of speaker text, are not memories by themselves. Distill a concrete fact/preference/decision/entity from them or skip.
- System/runtime artifacts: content containing "System:", compaction notices, model-switch/session-reset traces, tool-call transcripts, raw JSON blobs, or similar internal execution traces must be rejected unless a clean user fact can be extracted.
- Fragment blobs: mixed filename shards, code snippets, metadata fields, or partial sentences that look like unprocessed context fragments should be skipped rather than preserved.
- Fragment blobs: mixed filename shards, code snippets, metadata fields, or partial sentences that look like unprocessed context fragments should be skipped rather than preserved.${assistantBlocksRule}
- Atomic memory shape: each stored memory must read like one durable fact, preference, decision, entity state, event, case, or reusable pattern. If a candidate reads like an excerpt, log, or raw transcript, compress it into one atomic statement or skip it.
- Length/distillation gate: if a candidate is longer than about 200 characters and reads like raw conversation instead of a distilled insight, rewrite it as a single factual statement before storing; if that is not possible, skip it.

Expand Down Expand Up @@ -130,6 +168,18 @@ Notes:
- If nothing worth recording, return {"memories": []}
- Maximum 5 memories per extraction
- Preferences should be aggregated by topic`;
// "User: User" with a generic identity confused live agents; the name line
// only appears when a real name is configured.
const userNameLine = user && user !== "User" ? `User: ${user}\n\n` : "";
const userMessage = `${userNameLine}Target Output Language: auto (detect from recent messages)

${assistantEligible
? "Extract memory candidates from <user_message> and <assistant_message> blocks, attributed to their true speaker."
: "Extract memory candidates ONLY from <user_message> blocks."}

## Recent Conversation
${conversationText}`;
return { system, user: userMessage };
}
export function buildDedupPrompt(candidateAbstract, candidateOverview, candidateContent, existingMemories) {
return `Determine how to handle this candidate memory.
Expand Down
Loading