Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading