@team-harness/memory-algorithms 0.1.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/LICENSE +27 -0
- package/README.md +203 -0
- package/dist/contracts.d.ts +178 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +6 -0
- package/dist/runtime/documents.d.ts +15 -0
- package/dist/runtime/documents.js +209 -0
- package/dist/runtime/l1.d.ts +10 -0
- package/dist/runtime/l1.js +172 -0
- package/dist/runtime/run.d.ts +44 -0
- package/dist/runtime/run.js +191 -0
- package/dist/runtime/skill-workspace.d.ts +70 -0
- package/dist/runtime/skill-workspace.js +156 -0
- package/dist/runtime/skills.d.ts +9 -0
- package/dist/runtime/skills.js +48 -0
- package/dist/runtime/telemetry.d.ts +10 -0
- package/dist/runtime/telemetry.js +5 -0
- package/dist/runtime/tools.d.ts +15 -0
- package/dist/runtime/tools.js +5 -0
- package/dist/upstream/config.d.ts +1 -0
- package/dist/upstream/config.js +1 -0
- package/dist/upstream/core/conversation/l0-recorder.d.ts +6 -0
- package/dist/upstream/core/conversation/l0-recorder.js +1 -0
- package/dist/upstream/core/memory-prompt/composer.d.ts +6 -0
- package/dist/upstream/core/memory-prompt/composer.js +33 -0
- package/dist/upstream/core/memory-prompt/types.d.ts +103 -0
- package/dist/upstream/core/memory-prompt/types.js +21 -0
- package/dist/upstream/core/prompts/l1-dedup.d.ts +33 -0
- package/dist/upstream/core/prompts/l1-dedup.js +202 -0
- package/dist/upstream/core/prompts/l1-extraction.d.ts +24 -0
- package/dist/upstream/core/prompts/l1-extraction.js +400 -0
- package/dist/upstream/core/prompts/persona-generation.d.ts +29 -0
- package/dist/upstream/core/prompts/persona-generation.js +284 -0
- package/dist/upstream/core/prompts/scene-extraction.d.ts +40 -0
- package/dist/upstream/core/prompts/scene-extraction.js +534 -0
- package/dist/upstream/core/record/l1-dedup.d.ts +10 -0
- package/dist/upstream/core/record/l1-dedup.js +108 -0
- package/dist/upstream/core/record/l1-extractor.d.ts +33 -0
- package/dist/upstream/core/record/l1-extractor.js +128 -0
- package/dist/upstream/core/record/l1-writer.d.ts +95 -0
- package/dist/upstream/core/record/l1-writer.js +1 -0
- package/dist/upstream/core/scene/filename-normalizer.d.ts +6 -0
- package/dist/upstream/core/scene/filename-normalizer.js +30 -0
- package/dist/upstream/core/scene/scene-format.d.ts +26 -0
- package/dist/upstream/core/scene/scene-format.js +53 -0
- package/dist/upstream/core/scene/scene-index.d.ts +7 -0
- package/dist/upstream/core/scene/scene-index.js +1 -0
- package/dist/upstream/core/scene/scene-navigation.d.ts +66 -0
- package/dist/upstream/core/scene/scene-navigation.js +107 -0
- package/dist/upstream/core/skill/conversation-add/message-compressor.d.ts +47 -0
- package/dist/upstream/core/skill/conversation-add/message-compressor.js +58 -0
- package/dist/upstream/core/skill/conversation-add/oversize-strategy.d.ts +41 -0
- package/dist/upstream/core/skill/conversation-add/oversize-strategy.js +100 -0
- package/dist/upstream/core/skill/prompts/skill-review-prompt.d.ts +39 -0
- package/dist/upstream/core/skill/prompts/skill-review-prompt.js +197 -0
- package/dist/upstream/core/skill/skill-extractor.d.ts +146 -0
- package/dist/upstream/core/skill/skill-extractor.js +432 -0
- package/dist/upstream/core/skill/skill-format.d.ts +46 -0
- package/dist/upstream/core/skill/skill-format.js +191 -0
- package/dist/upstream/core/skill/skill-tools.d.ts +75 -0
- package/dist/upstream/core/skill/skill-tools.js +193 -0
- package/dist/upstream/core/skill/types.d.ts +324 -0
- package/dist/upstream/core/skill/types.js +7 -0
- package/dist/upstream/utils/sanitize.d.ts +96 -0
- package/dist/upstream/utils/sanitize.js +359 -0
- package/package.json +28 -0
- package/upstream/baseline.json +426 -0
- package/upstream/changes.md +81 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Logger } from "../../../contracts.js";
|
|
2
|
+
import type { MemoryType } from "./l1-writer.js";
|
|
3
|
+
interface SceneSegment {
|
|
4
|
+
scene_name: string;
|
|
5
|
+
message_ids: string[];
|
|
6
|
+
memories: Array<{
|
|
7
|
+
content: string;
|
|
8
|
+
type: string;
|
|
9
|
+
priority: number;
|
|
10
|
+
source_message_ids: string[];
|
|
11
|
+
metadata: Record<string, unknown>;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export type L1EmptyReason = "llm_error" | "no_json" | "parse_fail" | "not_array" | "empty_scenes";
|
|
15
|
+
interface ParseExtractionOutcome {
|
|
16
|
+
scenes: SceneSegment[];
|
|
17
|
+
/** Populated iff we ended with 0 memories across all scenes. */
|
|
18
|
+
emptyReason?: L1EmptyReason;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse the LLM's JSON response into SceneSegment array.
|
|
22
|
+
* Expected format: [{scene_name, message_ids, memories: [...]}]
|
|
23
|
+
*
|
|
24
|
+
* Diagnostics contract:
|
|
25
|
+
* - Debug-level [l1-debug] lines dump raw content on NO_JSON / PARSE_FAIL
|
|
26
|
+
* (unchanged from prior behavior).
|
|
27
|
+
* - The returned `emptyReason` is the CANONICAL machine-readable label —
|
|
28
|
+
* `extractL1Memories` uses it to emit a single-line `l1-empty` warn when
|
|
29
|
+
* the final count is 0. See L1EmptyReason for the closed set.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseExtractionResult(raw: string, logger?: Logger): ParseExtractionOutcome;
|
|
32
|
+
export declare function normalizeType(raw: string): MemoryType | null;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { sanitizeJsonForParse } from "../../utils/sanitize.js";
|
|
2
|
+
const TAG = "[l1-parser]";
|
|
3
|
+
const VALID_TYPES = ["persona", "episodic", "instruction", "work_fact", "work_task", "work_method", "work_artifact"];
|
|
4
|
+
/**
|
|
5
|
+
* Parse the LLM's JSON response into SceneSegment array.
|
|
6
|
+
* Expected format: [{scene_name, message_ids, memories: [...]}]
|
|
7
|
+
*
|
|
8
|
+
* Diagnostics contract:
|
|
9
|
+
* - Debug-level [l1-debug] lines dump raw content on NO_JSON / PARSE_FAIL
|
|
10
|
+
* (unchanged from prior behavior).
|
|
11
|
+
* - The returned `emptyReason` is the CANONICAL machine-readable label —
|
|
12
|
+
* `extractL1Memories` uses it to emit a single-line `l1-empty` warn when
|
|
13
|
+
* the final count is 0. See L1EmptyReason for the closed set.
|
|
14
|
+
*/
|
|
15
|
+
export function parseExtractionResult(raw, logger) {
|
|
16
|
+
try {
|
|
17
|
+
// ── Strip inline reasoning wrappers (A₂-style thinking models) ───────
|
|
18
|
+
// A₁ models (minimax-m2.7 / deepseek-v4-pro / o1 / o3 / Claude thinking)
|
|
19
|
+
// put reasoning in a separate `reasoning_content` field, so `content`
|
|
20
|
+
// itself is nothing but pure JSON — the strip below is a no-op.
|
|
21
|
+
//
|
|
22
|
+
// A₂ models (minimax-m3 / ep-2cocgw76 / GLM-4-thinking / qwq-32b / some
|
|
23
|
+
// vLLM-hosted DeepSeek-R1) inline reasoning as `<think>…</think>` inside
|
|
24
|
+
// the main `content`. Their think prose frequently contains stray `[`
|
|
25
|
+
// (message-id dumps, priority tags, and sometimes a full JSON template
|
|
26
|
+
// pre-written for "structure preview"), which fatally derails the greedy
|
|
27
|
+
// `/\[[\s\S]*\]/` array-match below by anchoring it inside the think
|
|
28
|
+
// section. So we peel `<think>` blocks BEFORE the array-match fires.
|
|
29
|
+
//
|
|
30
|
+
// Safeguards:
|
|
31
|
+
// • non-greedy `*?` + explicit `</think>` requirement — a truncated
|
|
32
|
+
// (max_tokens cap) think tag falls through as-is instead of eating
|
|
33
|
+
// the rest of the response.
|
|
34
|
+
// • `g` flag — rare multi-segment thinking is fully stripped.
|
|
35
|
+
// See docs/design/2026-09-03-l1-thinking-models-compatibility.md.
|
|
36
|
+
//
|
|
37
|
+
// NOTE: strip result goes to a new local (`stripped`) — we preserve the
|
|
38
|
+
// original `raw` so the NO_JSON / PARSE_FAIL dumps below still show
|
|
39
|
+
// exactly what came off the wire. If a future A₂ variant emits a shape
|
|
40
|
+
// this regex misses, ops need the pristine raw to diagnose it.
|
|
41
|
+
const stripped = raw.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
|
|
42
|
+
// Strip markdown code block wrappers if present
|
|
43
|
+
let cleaned = stripped.trim();
|
|
44
|
+
if (cleaned.startsWith("```")) {
|
|
45
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
46
|
+
}
|
|
47
|
+
// Try to extract JSON array
|
|
48
|
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
49
|
+
if (!arrayMatch) {
|
|
50
|
+
logger?.warn?.(`${TAG} No JSON array found in extraction response`);
|
|
51
|
+
// [l1-debug] NO_JSON — dump the full ORIGINAL raw (not the stripped
|
|
52
|
+
// version), otherwise a broken <think> variant would leave no trace.
|
|
53
|
+
const rawPreview = raw.slice(0, 2048);
|
|
54
|
+
logger?.warn?.(`${TAG} [l1-debug] NO_JSON taskId=l1-extraction, rawLen=${raw.length}, strippedLen=${stripped.length}, cleanedLen=${cleaned.length}, rawFull=${JSON.stringify(rawPreview)}${raw.length > 2048 ? `…(+${raw.length - 2048})` : ""}`);
|
|
55
|
+
return { scenes: [], emptyReason: "no_json" };
|
|
56
|
+
}
|
|
57
|
+
// Sanitize control characters inside JSON string literals that LLM may produce.
|
|
58
|
+
// Some weaker OpenAI-compatible models occasionally emit bare identifiers for
|
|
59
|
+
// numeric fields (e.g. `"priority": sheet`). Repair only known safe fields and
|
|
60
|
+
// retry once so one bad scalar does not drop the whole extraction result.
|
|
61
|
+
const sanitized = sanitizeJsonForParse(arrayMatch[0]);
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(sanitized);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const repaired = repairExtractionJson(sanitized);
|
|
68
|
+
if (repaired === sanitized)
|
|
69
|
+
throw err;
|
|
70
|
+
parsed = JSON.parse(repaired);
|
|
71
|
+
logger?.warn?.(`${TAG} Repaired non-strict extraction JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(parsed)) {
|
|
74
|
+
logger?.warn?.(`${TAG} Extraction response is not an array`);
|
|
75
|
+
return { scenes: [], emptyReason: "not_array" };
|
|
76
|
+
}
|
|
77
|
+
const scenes = [];
|
|
78
|
+
for (const item of parsed) {
|
|
79
|
+
if (!item || typeof item !== "object")
|
|
80
|
+
continue;
|
|
81
|
+
const s = item;
|
|
82
|
+
scenes.push({
|
|
83
|
+
scene_name: typeof s.scene_name === "string" ? s.scene_name : "未知情境",
|
|
84
|
+
message_ids: Array.isArray(s.message_ids) ? s.message_ids.map(String) : [],
|
|
85
|
+
memories: Array.isArray(s.memories)
|
|
86
|
+
? s.memories
|
|
87
|
+
.filter((m) => m && typeof m === "object" && typeof m.content === "string" && m.content.length > 0)
|
|
88
|
+
.map((m) => ({
|
|
89
|
+
content: String(m.content),
|
|
90
|
+
type: String(m.type ?? "episodic"),
|
|
91
|
+
priority: typeof m.priority === "number" ? m.priority : 50,
|
|
92
|
+
source_message_ids: Array.isArray(m.source_message_ids) ? m.source_message_ids.map(String) : [],
|
|
93
|
+
metadata: (m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
|
|
94
|
+
}))
|
|
95
|
+
: [],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const totalMemories = scenes.reduce((acc, sc) => acc + sc.memories.length, 0);
|
|
99
|
+
return {
|
|
100
|
+
scenes,
|
|
101
|
+
emptyReason: totalMemories === 0 ? "empty_scenes" : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger?.warn?.(`${TAG} Failed to parse extraction result: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
logger?.warn?.(`${TAG} [l1-debug] PARSE_FAIL rawLen=${raw.length}, rawFull=${JSON.stringify(raw)}`);
|
|
107
|
+
return { scenes: [], emptyReason: "parse_fail" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function repairExtractionJson(json) {
|
|
111
|
+
return json
|
|
112
|
+
.replace(/("priority"\s*:\s*)(?!-?\d+(?:\.\d+)?\s*[,}]|"[^"\\]*(?:\\.[^"\\]*)*"\s*[,}])([\s\S]*?)(?=,\s*"(?:content|type|priority|source_message_ids|metadata)"\s*:|[}\]])/g, (_m, prefix) => `${prefix}50`)
|
|
113
|
+
.replace(/,\s*([}\]])/g, "$1");
|
|
114
|
+
}
|
|
115
|
+
export function normalizeType(raw) {
|
|
116
|
+
const lower = raw.toLowerCase().trim();
|
|
117
|
+
if (VALID_TYPES.includes(lower)) {
|
|
118
|
+
return lower;
|
|
119
|
+
}
|
|
120
|
+
// Handle legacy type names
|
|
121
|
+
if (lower === "episode")
|
|
122
|
+
return "episodic";
|
|
123
|
+
if (lower === "instruct")
|
|
124
|
+
return "instruction";
|
|
125
|
+
if (lower === "preference")
|
|
126
|
+
return "persona"; // fold preference into persona
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type MemoryType = "persona" | "episodic" | "instruction" | "work_fact" | "work_task" | "work_method" | "work_artifact";
|
|
2
|
+
/** Metadata for episodic memories (activity time range) */
|
|
3
|
+
export interface EpisodicMetadata {
|
|
4
|
+
activity_start_time?: string;
|
|
5
|
+
activity_end_time?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A persisted memory record in L1 JSONL files.
|
|
9
|
+
*
|
|
10
|
+
* v3 changes from v2:
|
|
11
|
+
* - `importance: "high"|"medium"|"low"` → `priority: number` (0-100, -1 for strict global instructions)
|
|
12
|
+
* - Added `scene_name`, `source_message_ids`, `metadata`, `timestamps`
|
|
13
|
+
* - Removed `keywords` (will be rebuilt from content for search)
|
|
14
|
+
* - MemoryType reduced from 4 to 3 (removed "preference", folded into "persona")
|
|
15
|
+
*/
|
|
16
|
+
export interface MemoryRecord {
|
|
17
|
+
/** Unique ID for dedup updates */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Memory content */
|
|
20
|
+
content: string;
|
|
21
|
+
/** Memory type: persona / episodic / instruction */
|
|
22
|
+
type: MemoryType;
|
|
23
|
+
/** Priority score: 0-100 (higher = more important), -1 = strict global instruction */
|
|
24
|
+
priority: number;
|
|
25
|
+
/** Scene name this memory belongs to */
|
|
26
|
+
scene_name: string;
|
|
27
|
+
/** Source message IDs that contributed to this memory */
|
|
28
|
+
source_message_ids: string[];
|
|
29
|
+
/** Type-specific metadata (e.g., activity_start_time for episodic) */
|
|
30
|
+
metadata: EpisodicMetadata | Record<string, never>;
|
|
31
|
+
/** Timestamp trail: all timestamps related to this memory (for merge history tracking) */
|
|
32
|
+
timestamps: string[];
|
|
33
|
+
/** Creation timestamp (ISO) */
|
|
34
|
+
createdAt: string;
|
|
35
|
+
/** Last update timestamp (ISO) */
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
/** Monotonic version. New memories start at 1; update/merge increments by 1. */
|
|
38
|
+
version?: number;
|
|
39
|
+
/** Source session key (conversation channel identifier) */
|
|
40
|
+
sessionKey: string;
|
|
41
|
+
/** Source session ID (single conversation instance identifier) */
|
|
42
|
+
sessionId: string;
|
|
43
|
+
/** Optional task dimension for L0/L1 filtering. */
|
|
44
|
+
taskId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Three-dim tenancy isolation (new in this branch).
|
|
47
|
+
*
|
|
48
|
+
* `userId` / `agentId` are mandatory for new writes once gateway-level
|
|
49
|
+
* isolation enforcement is on, but kept optional on the type to avoid
|
|
50
|
+
* breaking pre-isolation call sites and tests during rollout. The SQLite
|
|
51
|
+
* upsert defaults them to '' if missing; the migration script backfills
|
|
52
|
+
* existing rows with `__legacy__`.
|
|
53
|
+
*
|
|
54
|
+
* See `docs/l0l3-tenant-isolation-design.md`.
|
|
55
|
+
*/
|
|
56
|
+
teamId?: string;
|
|
57
|
+
userId?: string;
|
|
58
|
+
agentId?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A memory as extracted by LLM (before dedup / persistence).
|
|
62
|
+
* Matches the output format of Kenty's extraction prompt.
|
|
63
|
+
*/
|
|
64
|
+
export interface ExtractedMemory {
|
|
65
|
+
content: string;
|
|
66
|
+
type: MemoryType;
|
|
67
|
+
priority: number;
|
|
68
|
+
source_message_ids: string[];
|
|
69
|
+
metadata: EpisodicMetadata | Record<string, never>;
|
|
70
|
+
/** Scene name this memory was extracted in */
|
|
71
|
+
scene_name: string;
|
|
72
|
+
}
|
|
73
|
+
export type DedupAction = "store" | "update" | "merge" | "skip";
|
|
74
|
+
/**
|
|
75
|
+
* v3 batch dedup decision — one per new memory, aligned with Kenty's conflict detection prompt.
|
|
76
|
+
*
|
|
77
|
+
* Key changes:
|
|
78
|
+
* - `targetId` → `target_ids` (array, supports multi-target merge/update)
|
|
79
|
+
* - Added `merged_type`, `merged_priority`, `merged_timestamps` for cross-type merge
|
|
80
|
+
*/
|
|
81
|
+
export interface DedupDecision {
|
|
82
|
+
/** Which new memory this decision is about */
|
|
83
|
+
record_id: string;
|
|
84
|
+
action: DedupAction;
|
|
85
|
+
/** IDs of existing records to replace/remove (for update/merge) */
|
|
86
|
+
target_ids: string[];
|
|
87
|
+
/** Merged/updated content text (for update/merge) */
|
|
88
|
+
merged_content?: string;
|
|
89
|
+
/** Best type after merge (for update/merge, may differ from original) */
|
|
90
|
+
merged_type?: MemoryType;
|
|
91
|
+
/** Priority after merge (for update/merge) */
|
|
92
|
+
merged_priority?: number;
|
|
93
|
+
/** Union of all related timestamps (for update/merge) */
|
|
94
|
+
merged_timestamps?: string[];
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function normalizeSceneFilename(name: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Return whether a filename already matches its normalized form.
|
|
4
|
+
* Faster than computing the normalized form when callers only need a yes/no.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isNormalizedSceneFilename(name: string): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function normalizeSceneFilename(name) {
|
|
2
|
+
if (!name)
|
|
3
|
+
return "scene.md";
|
|
4
|
+
// Strip directory components defensively — we only normalize the basename.
|
|
5
|
+
const base = name.replace(/^.*[\\/]/, "");
|
|
6
|
+
// Detect & strip `.md` (case-insensitive). Always re-emit lowercase `.md`.
|
|
7
|
+
const lower = base.toLowerCase();
|
|
8
|
+
const hasMd = lower.endsWith(".md");
|
|
9
|
+
const stem = hasMd ? base.slice(0, -3) : base;
|
|
10
|
+
const safe = stem
|
|
11
|
+
// Replace whitespace runs (incl. NBSP, full-width space) with `-`
|
|
12
|
+
.replace(/[\s\u00A0\u3000]+/g, "-")
|
|
13
|
+
// Drop quotes, brackets, and punctuation known to break shells/markdown.
|
|
14
|
+
// Keep alphanumerics, CJK ideographs, `-`, `_`, `.`.
|
|
15
|
+
.replace(/[()[\]{}<>'"`,;:!?*|/\\=&%$#@^~+]/g, "")
|
|
16
|
+
// Collapse consecutive separators.
|
|
17
|
+
.replace(/-{2,}/g, "-")
|
|
18
|
+
.replace(/_{2,}/g, "_")
|
|
19
|
+
.replace(/\.{2,}/g, ".")
|
|
20
|
+
// Trim leading / trailing separators.
|
|
21
|
+
.replace(/^[-_.]+|[-_.]+$/g, "");
|
|
22
|
+
return (safe || "scene") + ".md";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Return whether a filename already matches its normalized form.
|
|
26
|
+
* Faster than computing the normalized form when callers only need a yes/no.
|
|
27
|
+
*/
|
|
28
|
+
export function isNormalizedSceneFilename(name) {
|
|
29
|
+
return normalizeSceneFilename(name) === name;
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene Block file format: parse and format the META-delimited Markdown files.
|
|
3
|
+
*/
|
|
4
|
+
export interface SceneBlockMeta {
|
|
5
|
+
created: string;
|
|
6
|
+
updated: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
heat: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SceneBlock {
|
|
11
|
+
filename: string;
|
|
12
|
+
meta: SceneBlockMeta;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a Scene Block file into structured data.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSceneBlock(raw: string, filename: string): SceneBlock;
|
|
19
|
+
/**
|
|
20
|
+
* Format a Scene Block back into file content.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatSceneBlock(meta: SceneBlockMeta, content: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Format the META section.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatMeta(meta: SceneBlockMeta): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene Block file format: parse and format the META-delimited Markdown files.
|
|
3
|
+
*/
|
|
4
|
+
const META_START = "-----META-START-----";
|
|
5
|
+
const META_END = "-----META-END-----";
|
|
6
|
+
/**
|
|
7
|
+
* Parse a Scene Block file into structured data.
|
|
8
|
+
*/
|
|
9
|
+
export function parseSceneBlock(raw, filename) {
|
|
10
|
+
const startIdx = raw.indexOf(META_START);
|
|
11
|
+
const endIdx = raw.indexOf(META_END);
|
|
12
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
13
|
+
// No META section — treat entire file as content
|
|
14
|
+
return {
|
|
15
|
+
filename,
|
|
16
|
+
meta: { created: "", updated: "", summary: "", heat: 0 },
|
|
17
|
+
content: raw.trim(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const metaBlock = raw.slice(startIdx + META_START.length, endIdx).trim();
|
|
21
|
+
const content = raw.slice(endIdx + META_END.length).trim();
|
|
22
|
+
const meta = {
|
|
23
|
+
created: extractMetaField(metaBlock, "created"),
|
|
24
|
+
updated: extractMetaField(metaBlock, "updated"),
|
|
25
|
+
summary: extractMetaField(metaBlock, "summary"),
|
|
26
|
+
heat: parseInt(extractMetaField(metaBlock, "heat"), 10) || 0,
|
|
27
|
+
};
|
|
28
|
+
return { filename, meta, content };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Format a Scene Block back into file content.
|
|
32
|
+
*/
|
|
33
|
+
export function formatSceneBlock(meta, content) {
|
|
34
|
+
return `${formatMeta(meta)}\n\n${content}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format the META section.
|
|
38
|
+
*/
|
|
39
|
+
export function formatMeta(meta) {
|
|
40
|
+
return [
|
|
41
|
+
META_START,
|
|
42
|
+
`created: ${meta.created}`,
|
|
43
|
+
`updated: ${meta.updated}`,
|
|
44
|
+
`summary: ${meta.summary}`,
|
|
45
|
+
`heat: ${meta.heat}`,
|
|
46
|
+
META_END,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
function extractMetaField(metaBlock, field) {
|
|
50
|
+
const re = new RegExp(`^${field}:\\s*(.*)$`, "m");
|
|
51
|
+
const m = metaBlock.match(re);
|
|
52
|
+
return m ? m[1].trim() : "";
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene navigation: generates a summary navigation section appended to persona.md.
|
|
3
|
+
*
|
|
4
|
+
* The navigation includes **absolute** file paths so the agent can directly
|
|
5
|
+
* use read_file for on-demand scene loading (progressive disclosure).
|
|
6
|
+
*/
|
|
7
|
+
import type { SceneIndexEntry } from "./scene-index.js";
|
|
8
|
+
/**
|
|
9
|
+
* Footer for backends addressed by storage key rather than filesystem path.
|
|
10
|
+
* The reading tool is a property of the mounted surface, not of the backend,
|
|
11
|
+
* so it is supplied by whoever assembles the two.
|
|
12
|
+
*/
|
|
13
|
+
export declare function storageNavFooter(readTool: string): string;
|
|
14
|
+
export interface SceneNavigationRenderOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Resolves an entry to the path the reader must pass back. Callers hand in
|
|
17
|
+
* the same resolver they serve reads from, so a rendered path is by
|
|
18
|
+
* construction a path that can be read (design doc D12).
|
|
19
|
+
*/
|
|
20
|
+
pathFor: (entry: SceneIndexEntry) => string;
|
|
21
|
+
/** Tool the model should call, named in the intro line. */
|
|
22
|
+
readTool: string;
|
|
23
|
+
footer: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate the scene navigation Markdown section.
|
|
27
|
+
*
|
|
28
|
+
* @param entries - Scene index entries
|
|
29
|
+
* @param dataDir - Absolute path to the plugin data directory; when provided
|
|
30
|
+
* and useCos=false, paths are absolute for read_file.
|
|
31
|
+
* @param useCos - When true, paths use scenes/ prefix and footer says tdai_read_file.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ KNOWN BROKEN — the `useCos=true` branch emits unreadable paths. Do not
|
|
34
|
+
* build on it, and do not "fix" it by renaming scenes/ -> scene_blocks/ alone.
|
|
35
|
+
*
|
|
36
|
+
* It is wrong on two levels: the directory name is a leftover from the deleted
|
|
37
|
+
* CosPathResolver design (real layout is scene_blocks/), and the emitted key
|
|
38
|
+
* also omits the profiles/{scope}/ segment, because navigation is generated
|
|
39
|
+
* against a scoped storage view while the read tool holds an unscoped root.
|
|
40
|
+
*
|
|
41
|
+
* Left unfixed deliberately: no live read path reaches it. The Proxy reads L2
|
|
42
|
+
* through /v3/scenario/ls + /v3/scenario/read, and the OpenClaw plugin builds
|
|
43
|
+
* TdaiCore without a storage adapter, so useCos is always false there. The one
|
|
44
|
+
* caller that can set useCos=true is the legacy POST /recall endpoint, which
|
|
45
|
+
* currently has no consumer.
|
|
46
|
+
*
|
|
47
|
+
* Reviving POST /recall on the main path REQUIRES fixing this first. The real
|
|
48
|
+
* fix is to make navigation and the read tool share one scoped resolver, which
|
|
49
|
+
* is tracked as P10.
|
|
50
|
+
*
|
|
51
|
+
* See docs/design/mongodb/design/2026-08-27-core-storage-abstraction-design.md
|
|
52
|
+
* (D13, §10.C3.1, known issue L6).
|
|
53
|
+
*/
|
|
54
|
+
export declare function generateSceneNavigation(entries: SceneIndexEntry[], dataDir?: string, useCos?: boolean): string;
|
|
55
|
+
/**
|
|
56
|
+
* Render the navigation section from entries and a caller-supplied resolver.
|
|
57
|
+
*
|
|
58
|
+
* Taking `pathFor` instead of a mode flag is the point: a backend renders the
|
|
59
|
+
* paths it actually serves, so navigation and reads cannot drift apart the way
|
|
60
|
+
* the `useCos` branch above did.
|
|
61
|
+
*/
|
|
62
|
+
export declare function renderSceneNavigation(entries: SceneIndexEntry[], opts: SceneNavigationRenderOptions): string;
|
|
63
|
+
/**
|
|
64
|
+
* Strip the scene navigation section from persona content.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stripSceneNavigation(personaContent: string): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene navigation: generates a summary navigation section appended to persona.md.
|
|
3
|
+
*
|
|
4
|
+
* The navigation includes **absolute** file paths so the agent can directly
|
|
5
|
+
* use read_file for on-demand scene loading (progressive disclosure).
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
const NAV_HEADER = "---\n## 🗺️ Scene Navigation (Scene Index)";
|
|
9
|
+
const NAV_FOOTER_LOCAL = `📌 使用说明:
|
|
10
|
+
- Path 是 scene block 的绝对路径,可直接使用 **read** 工具读取完整内容(参数: filePath)
|
|
11
|
+
- 热度:该场景被记忆命中的累计次数,越高越重要
|
|
12
|
+
- Summary:场景的核心要点摘要`;
|
|
13
|
+
/**
|
|
14
|
+
* Footer for backends addressed by storage key rather than filesystem path.
|
|
15
|
+
* The reading tool is a property of the mounted surface, not of the backend,
|
|
16
|
+
* so it is supplied by whoever assembles the two.
|
|
17
|
+
*/
|
|
18
|
+
export function storageNavFooter(readTool) {
|
|
19
|
+
return `📌 使用说明:
|
|
20
|
+
- Path 是 scene block 的存储路径,请使用 **${readTool}** 工具读取完整内容(参数: path)
|
|
21
|
+
- 热度:该场景被记忆命中的累计次数,越高越重要
|
|
22
|
+
- Summary:场景的核心要点摘要`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build a fire-emoji string based on heat value (visual priority cue for the agent).
|
|
26
|
+
*/
|
|
27
|
+
function heatEmoji(heat) {
|
|
28
|
+
if (heat >= 1000)
|
|
29
|
+
return " 🔥🔥🔥🔥🔥";
|
|
30
|
+
if (heat >= 500)
|
|
31
|
+
return " 🔥🔥🔥🔥";
|
|
32
|
+
if (heat >= 200)
|
|
33
|
+
return " 🔥🔥🔥";
|
|
34
|
+
if (heat >= 100)
|
|
35
|
+
return " 🔥🔥";
|
|
36
|
+
if (heat >= 50)
|
|
37
|
+
return " 🔥";
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generate the scene navigation Markdown section.
|
|
42
|
+
*
|
|
43
|
+
* @param entries - Scene index entries
|
|
44
|
+
* @param dataDir - Absolute path to the plugin data directory; when provided
|
|
45
|
+
* and useCos=false, paths are absolute for read_file.
|
|
46
|
+
* @param useCos - When true, paths use scenes/ prefix and footer says tdai_read_file.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ KNOWN BROKEN — the `useCos=true` branch emits unreadable paths. Do not
|
|
49
|
+
* build on it, and do not "fix" it by renaming scenes/ -> scene_blocks/ alone.
|
|
50
|
+
*
|
|
51
|
+
* It is wrong on two levels: the directory name is a leftover from the deleted
|
|
52
|
+
* CosPathResolver design (real layout is scene_blocks/), and the emitted key
|
|
53
|
+
* also omits the profiles/{scope}/ segment, because navigation is generated
|
|
54
|
+
* against a scoped storage view while the read tool holds an unscoped root.
|
|
55
|
+
*
|
|
56
|
+
* Left unfixed deliberately: no live read path reaches it. The Proxy reads L2
|
|
57
|
+
* through /v3/scenario/ls + /v3/scenario/read, and the OpenClaw plugin builds
|
|
58
|
+
* TdaiCore without a storage adapter, so useCos is always false there. The one
|
|
59
|
+
* caller that can set useCos=true is the legacy POST /recall endpoint, which
|
|
60
|
+
* currently has no consumer.
|
|
61
|
+
*
|
|
62
|
+
* Reviving POST /recall on the main path REQUIRES fixing this first. The real
|
|
63
|
+
* fix is to make navigation and the read tool share one scoped resolver, which
|
|
64
|
+
* is tracked as P10.
|
|
65
|
+
*
|
|
66
|
+
* See docs/design/mongodb/design/2026-08-27-core-storage-abstraction-design.md
|
|
67
|
+
* (D13, §10.C3.1, known issue L6).
|
|
68
|
+
*/
|
|
69
|
+
export function generateSceneNavigation(entries, dataDir, useCos = false) {
|
|
70
|
+
return renderSceneNavigation(entries, {
|
|
71
|
+
pathFor: (e) => useCos
|
|
72
|
+
? `scenes/${e.filename}`
|
|
73
|
+
: dataDir
|
|
74
|
+
? path.join(dataDir, "scene_blocks", e.filename)
|
|
75
|
+
: `scene_blocks/${e.filename}`,
|
|
76
|
+
readTool: useCos ? "tdai_read_file" : "read",
|
|
77
|
+
footer: useCos ? storageNavFooter("tdai_read_file") : NAV_FOOTER_LOCAL,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render the navigation section from entries and a caller-supplied resolver.
|
|
82
|
+
*
|
|
83
|
+
* Taking `pathFor` instead of a mode flag is the point: a backend renders the
|
|
84
|
+
* paths it actually serves, so navigation and reads cannot drift apart the way
|
|
85
|
+
* the `useCos` branch above did.
|
|
86
|
+
*/
|
|
87
|
+
export function renderSceneNavigation(entries, opts) {
|
|
88
|
+
if (entries.length === 0)
|
|
89
|
+
return "";
|
|
90
|
+
const sorted = [...entries].sort((a, b) => b.heat - a.heat);
|
|
91
|
+
const blocks = sorted.map((e) => {
|
|
92
|
+
const pathLine = `### Path: ${opts.pathFor(e)}`;
|
|
93
|
+
const heatLine = `**热度**: ${e.heat}${heatEmoji(e.heat)}${e.updated ? ` | **更新**: ${e.updated}` : ""}`;
|
|
94
|
+
const summaryLine = `Summary: ${e.summary}`;
|
|
95
|
+
return `${pathLine}\n${heatLine}\n${summaryLine}`;
|
|
96
|
+
});
|
|
97
|
+
return `${NAV_HEADER}\n*以下是当前场景记忆的索引,可根据需要 ${opts.readTool} 读取详细内容。*\n\n${blocks.join("\n\n")}\n\n${opts.footer}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Strip the scene navigation section from persona content.
|
|
101
|
+
*/
|
|
102
|
+
export function stripSceneNavigation(personaContent) {
|
|
103
|
+
const idx = personaContent.indexOf(NAV_HEADER);
|
|
104
|
+
if (idx === -1)
|
|
105
|
+
return personaContent;
|
|
106
|
+
return personaContent.slice(0, idx).trimEnd();
|
|
107
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillMessageCompressor — compress oversized tool_call / tool_result payloads
|
|
3
|
+
* before they enter the skill buffer.
|
|
4
|
+
*
|
|
5
|
+
* 对应设计文档 `2026-07-15-skill-trigger-in-core-design.md` §6:
|
|
6
|
+
* - 只压 tool_call / tool_result;user / assistant / system 永不压缩
|
|
7
|
+
* - content 字节数 > 2KB 才压缩,头 1KB + 尾 1KB + 中间占位提示
|
|
8
|
+
* - metadata.truncated = true, metadata.original_bytes = <原始字节数>
|
|
9
|
+
*
|
|
10
|
+
* 字节切分实现说明:
|
|
11
|
+
* 直接对 Buffer 做 slice 可能截到 UTF-8 多字节字符的中间,转回 string 会
|
|
12
|
+
* 出现 U+FFFD 替换字符。这里我们对 Buffer 做切片、再 toString('utf8'),
|
|
13
|
+
* Node 侧会把结尾/开头不完整的多字节序列替换成 U+FFFD——但整体不影响
|
|
14
|
+
* 下游 LLM review 的语义(提示语里说明了截断)。对于 tool payload 这种
|
|
15
|
+
* 通常是 ASCII/JSON 的场景,边界字符损坏概率极低;测试对齐宽松断言。
|
|
16
|
+
*/
|
|
17
|
+
export type CompressibleRole = "user" | "assistant" | "tool_call" | "tool_result" | "system";
|
|
18
|
+
export interface CompressibleMessage {
|
|
19
|
+
role: CompressibleRole;
|
|
20
|
+
content: string;
|
|
21
|
+
/** Optional tool identity for tool_call / tool_result. */
|
|
22
|
+
tool_name?: string;
|
|
23
|
+
tool_call_id?: string;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface CompressOptions {
|
|
28
|
+
/** 字节阈值;tool 消息 content bytes > 阈值才压缩。默认 2048 (2KB)。 */
|
|
29
|
+
toolContentThresholdBytes: number;
|
|
30
|
+
/** 头部保留字节数。默认 1024 (1KB)。 */
|
|
31
|
+
headBytes: number;
|
|
32
|
+
/** 尾部保留字节数。默认 1024 (1KB)。 */
|
|
33
|
+
tailBytes: number;
|
|
34
|
+
/** 中间占位字符串。 */
|
|
35
|
+
placeholder: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const DEFAULT_COMPRESS_OPTIONS: CompressOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Compress a single message. Returns a new object if compressed, otherwise
|
|
40
|
+
* returns the original message reference unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export declare function compressMessage(msg: CompressibleMessage, optsOverride?: Partial<CompressOptions>): CompressibleMessage;
|
|
43
|
+
/**
|
|
44
|
+
* Compress an array of messages. Returns a new array; unchanged messages
|
|
45
|
+
* share the original reference (identity-preserving for downstream diffing).
|
|
46
|
+
*/
|
|
47
|
+
export declare function compressMessages(messages: CompressibleMessage[], optsOverride?: Partial<CompressOptions>): CompressibleMessage[];
|