dsh-tdai-memory 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 +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Memory Reader: reads persisted L1 memory records.
|
|
3
|
+
*
|
|
4
|
+
* Provides two data paths:
|
|
5
|
+
*
|
|
6
|
+
* 1. **SQLite** (preferred): `queryMemoryRecords()` — uses VectorStore's `queryL1Records()`
|
|
7
|
+
* with composite indexes on (session_key, updated_time) and (session_id, updated_time)
|
|
8
|
+
* for efficient session-scoped and time-range queries.
|
|
9
|
+
*
|
|
10
|
+
* 2. **JSONL** (fallback): `readMemoryRecords()` / `readAllMemoryRecords()` — reads from
|
|
11
|
+
* `records/YYYY-MM-DD.jsonl` files. Used when VectorStore is unavailable or degraded.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
const TAG = "[memory-tdai] [l1-reader]";
|
|
16
|
+
// ============================
|
|
17
|
+
// SQLite-based queries (preferred)
|
|
18
|
+
// ============================
|
|
19
|
+
/**
|
|
20
|
+
* Query L1 memory records from SQLite via VectorStore.
|
|
21
|
+
*
|
|
22
|
+
* This is the **preferred** read path — it uses the composite index
|
|
23
|
+
* `idx_l1_session_updated(session_id, updated_time)` for efficient
|
|
24
|
+
* session-scoped and time-range queries.
|
|
25
|
+
*
|
|
26
|
+
* All timestamps are UTC ISO 8601 (as stored by l1-writer's dual-write).
|
|
27
|
+
*
|
|
28
|
+
* Falls back to empty array if VectorStore is null or degraded.
|
|
29
|
+
*/
|
|
30
|
+
export async function queryMemoryRecords(vectorStore, filter, logger) {
|
|
31
|
+
if (!vectorStore) {
|
|
32
|
+
logger?.warn(`${TAG} queryMemoryRecords: no VectorStore available, returning empty`);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const rows = await vectorStore.queryL1Records(filter);
|
|
36
|
+
return rows.map(rowToMemoryRecord);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert a raw SQLite L1RecordRow to a MemoryRecord (same shape as JSONL records).
|
|
40
|
+
*/
|
|
41
|
+
function rowToMemoryRecord(row) {
|
|
42
|
+
let metadata = {};
|
|
43
|
+
try {
|
|
44
|
+
metadata = JSON.parse(row.metadata_json);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// malformed JSON — use empty object
|
|
48
|
+
}
|
|
49
|
+
// Reconstruct timestamps array from timestamp_start / timestamp_end
|
|
50
|
+
const timestamps = [];
|
|
51
|
+
if (row.timestamp_str)
|
|
52
|
+
timestamps.push(row.timestamp_str);
|
|
53
|
+
if (row.timestamp_start && row.timestamp_start !== row.timestamp_str)
|
|
54
|
+
timestamps.push(row.timestamp_start);
|
|
55
|
+
if (row.timestamp_end && row.timestamp_end !== row.timestamp_str && row.timestamp_end !== row.timestamp_start) {
|
|
56
|
+
timestamps.push(row.timestamp_end);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
id: row.record_id,
|
|
60
|
+
content: row.content,
|
|
61
|
+
type: row.type,
|
|
62
|
+
priority: row.priority,
|
|
63
|
+
scene_name: row.scene_name,
|
|
64
|
+
source_message_ids: [], // not stored in SQLite (vector search doesn't need them)
|
|
65
|
+
metadata,
|
|
66
|
+
timestamps,
|
|
67
|
+
createdAt: row.created_time,
|
|
68
|
+
updatedAt: row.updated_time,
|
|
69
|
+
sessionKey: row.session_key,
|
|
70
|
+
sessionId: row.session_id,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// ============================
|
|
74
|
+
// JSONL-based reads (fallback)
|
|
75
|
+
// ============================
|
|
76
|
+
/**
|
|
77
|
+
* Read all memory records for a session from JSONL files.
|
|
78
|
+
*
|
|
79
|
+
* Current naming mode:
|
|
80
|
+
* - Daily merged file: records/YYYY-MM-DD.jsonl (all sessions in one file)
|
|
81
|
+
*/
|
|
82
|
+
export async function readMemoryRecords(sessionKey, baseDir, logger) {
|
|
83
|
+
const recordsDir = path.join(baseDir, "records");
|
|
84
|
+
const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await fs.readdir(recordsDir, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Directory doesn't exist yet
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const targetFiles = entries
|
|
94
|
+
.filter((entry) => entry.isFile() && dateFilePattern.test(entry.name))
|
|
95
|
+
.map((entry) => entry.name)
|
|
96
|
+
.sort();
|
|
97
|
+
if (targetFiles.length === 0) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const records = [];
|
|
101
|
+
for (const fileName of targetFiles) {
|
|
102
|
+
const filePath = path.join(recordsDir, fileName);
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
logger?.warn?.(`${TAG} Failed to read L1 file: ${filePath}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const lines = raw.split("\n").filter((line) => line.trim());
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(line);
|
|
116
|
+
if (parsed.sessionKey !== sessionKey) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
records.push(parsed);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
records.sort((a, b) => {
|
|
127
|
+
const ta = a.updatedAt || a.createdAt || "";
|
|
128
|
+
const tb = b.updatedAt || b.createdAt || "";
|
|
129
|
+
return ta.localeCompare(tb);
|
|
130
|
+
});
|
|
131
|
+
return records;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read ALL memory records across all session JSONL files.
|
|
135
|
+
*/
|
|
136
|
+
export async function readAllMemoryRecords(baseDir, logger) {
|
|
137
|
+
const recordsDir = path.join(baseDir, "records");
|
|
138
|
+
try {
|
|
139
|
+
const files = await fs.readdir(recordsDir);
|
|
140
|
+
const allRecords = [];
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
if (!file.endsWith(".jsonl"))
|
|
143
|
+
continue;
|
|
144
|
+
const filePath = path.join(recordsDir, file);
|
|
145
|
+
try {
|
|
146
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
147
|
+
const lines = raw.split("\n").filter((line) => line.trim());
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
try {
|
|
150
|
+
allRecords.push(JSON.parse(line));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${file}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
logger?.warn?.(`${TAG} Failed to read ${file}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
allRecords.sort((a, b) => {
|
|
162
|
+
const ta = a.updatedAt || a.createdAt || "";
|
|
163
|
+
const tb = b.updatedAt || b.createdAt || "";
|
|
164
|
+
return ta.localeCompare(tb);
|
|
165
|
+
});
|
|
166
|
+
return allRecords;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// records/ directory doesn't exist yet
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// ============================
|
|
174
|
+
// Helpers
|
|
175
|
+
// ============================
|
|
176
|
+
function sanitizeFilename(name) {
|
|
177
|
+
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
|
|
178
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Memory Writer: writes extracted memories to JSONL files.
|
|
3
|
+
*
|
|
4
|
+
* File naming: records/YYYY-MM-DD.jsonl (daily shards, all sessions merged).
|
|
5
|
+
* Each record includes sessionKey for traceability.
|
|
6
|
+
*
|
|
7
|
+
* Write strategy:
|
|
8
|
+
* - JSONL is the append-only persistent store (source of truth for backup/recovery).
|
|
9
|
+
* - VectorStore (SQLite) is the primary retrieval engine.
|
|
10
|
+
* - On update/merge, old records are deleted from VectorStore in real-time;
|
|
11
|
+
* JSONL is append-only and cleaned up periodically by memory-cleaner.
|
|
12
|
+
*
|
|
13
|
+
* Supports store (append), update, merge, and skip operations.
|
|
14
|
+
*
|
|
15
|
+
* v3: Aligned with Kenty's prompt output format — 3 memory types (persona/episodic/instruction),
|
|
16
|
+
* numeric priority, scene_name, source_message_ids, metadata, timestamps.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import { formatLocalDate } from "../../utils/time.js";
|
|
22
|
+
const TAG = "[memory-tdai][l1-writer]";
|
|
23
|
+
// ============================
|
|
24
|
+
// Core functions
|
|
25
|
+
// ============================
|
|
26
|
+
/**
|
|
27
|
+
* Generate a unique memory ID.
|
|
28
|
+
*/
|
|
29
|
+
export function generateMemoryId() {
|
|
30
|
+
return `m_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Write a memory record according to the dedup decision.
|
|
34
|
+
*
|
|
35
|
+
* - store: append new record
|
|
36
|
+
* - update: remove target records + append updated record
|
|
37
|
+
* - merge: remove target records + append merged record
|
|
38
|
+
* - skip: do nothing
|
|
39
|
+
*
|
|
40
|
+
* v3: supports multi-target removal for update/merge.
|
|
41
|
+
* v3.1: optional VectorStore + EmbeddingService for dual-write (JSONL + vector).
|
|
42
|
+
*/
|
|
43
|
+
export async function writeMemory(params) {
|
|
44
|
+
const { memory, decision, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
|
|
45
|
+
if (decision.action === "skip") {
|
|
46
|
+
logger?.debug?.(`${TAG} Skipping memory: ${memory.content.slice(0, 50)}...`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
// Determine final content, type, priority based on action
|
|
51
|
+
let finalContent;
|
|
52
|
+
let finalType;
|
|
53
|
+
let finalPriority;
|
|
54
|
+
let finalTimestamps;
|
|
55
|
+
if (decision.action === "merge" || decision.action === "update") {
|
|
56
|
+
finalContent = decision.merged_content ?? memory.content;
|
|
57
|
+
finalType = decision.merged_type ?? memory.type;
|
|
58
|
+
finalPriority = decision.merged_priority ?? memory.priority;
|
|
59
|
+
finalTimestamps = decision.merged_timestamps ?? [now];
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// store
|
|
63
|
+
finalContent = memory.content;
|
|
64
|
+
finalType = memory.type;
|
|
65
|
+
finalPriority = memory.priority;
|
|
66
|
+
finalTimestamps = [now];
|
|
67
|
+
}
|
|
68
|
+
const record = {
|
|
69
|
+
id: decision.record_id || generateMemoryId(),
|
|
70
|
+
content: finalContent,
|
|
71
|
+
type: finalType,
|
|
72
|
+
priority: finalPriority,
|
|
73
|
+
scene_name: memory.scene_name,
|
|
74
|
+
source_message_ids: memory.source_message_ids,
|
|
75
|
+
metadata: memory.metadata,
|
|
76
|
+
timestamps: finalTimestamps,
|
|
77
|
+
createdAt: now,
|
|
78
|
+
updatedAt: now,
|
|
79
|
+
sessionKey,
|
|
80
|
+
sessionId: sessionId || "",
|
|
81
|
+
};
|
|
82
|
+
const recordsDir = path.join(baseDir, "records");
|
|
83
|
+
await fs.mkdir(recordsDir, { recursive: true });
|
|
84
|
+
const shardDate = formatLocalDate(new Date());
|
|
85
|
+
const filePath = path.join(recordsDir, `${shardDate}.jsonl`);
|
|
86
|
+
if ((decision.action === "update" || decision.action === "merge") && decision.target_ids.length > 0) {
|
|
87
|
+
// Remove target records from VectorStore (real-time deletion for retrieval accuracy).
|
|
88
|
+
// JSONL is append-only — old records remain in files and are cleaned up periodically
|
|
89
|
+
// by memory-cleaner (which reconciles against VectorStore as source of truth).
|
|
90
|
+
if (vectorStore) {
|
|
91
|
+
try {
|
|
92
|
+
await vectorStore.deleteL1Batch(decision.target_ids);
|
|
93
|
+
logger?.debug?.(`${TAG} VectorStore: deleted ${decision.target_ids.length} target record(s) for ${decision.action}`);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
logger?.warn?.(`${TAG} VectorStore delete failed for ${decision.action}: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
|
|
100
|
+
logger?.debug?.(`${TAG} ${decision.action} memory: removed [${decision.target_ids.join(",")}] from VectorStore → ${record.id}: ${finalContent.slice(0, 80)}...`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// store: append a new line
|
|
104
|
+
await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
|
|
105
|
+
logger?.debug?.(`${TAG} Stored memory ${record.id}: ${finalContent.slice(0, 80)}...`);
|
|
106
|
+
}
|
|
107
|
+
// === Vector Store dual-write ===
|
|
108
|
+
if (vectorStore) {
|
|
109
|
+
try {
|
|
110
|
+
logger?.debug?.(`${TAG} [vec-dual-write] START id=${record.id}, contentLen=${record.content.length}, ` +
|
|
111
|
+
`content="${record.content.slice(0, 80)}..."`);
|
|
112
|
+
let embedding;
|
|
113
|
+
if (embeddingService) {
|
|
114
|
+
try {
|
|
115
|
+
embedding = await embeddingService.embed(record.content);
|
|
116
|
+
logger?.debug?.(`${TAG} [vec-dual-write] Embedding OK: dims=${embedding.length}, ` +
|
|
117
|
+
`norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`);
|
|
118
|
+
}
|
|
119
|
+
catch (embedErr) {
|
|
120
|
+
// Embedding failed — pass undefined to upsert() which writes
|
|
121
|
+
// metadata + FTS only, skipping the vec0 table.
|
|
122
|
+
logger?.warn(`${TAG} [vec-dual-write] Embedding FAILED for id=${record.id}, ` +
|
|
123
|
+
`will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const upsertOk = await vectorStore.upsertL1(record, embedding);
|
|
127
|
+
logger?.debug?.(`${TAG} [vec-dual-write] upsert result=${upsertOk} id=${record.id}`);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
// Vector write failure should NOT block the main JSONL write
|
|
131
|
+
logger?.warn?.(`${TAG} [vec-dual-write] FAILED (JSONL already written) id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
logger?.debug?.(`${TAG} [vec-dual-write] SKIPPED id=${record.id}: vectorStore=${!!vectorStore}`);
|
|
136
|
+
}
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
// ============================
|
|
140
|
+
// Helpers
|
|
141
|
+
// ============================
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const REPORT_CONST = {
|
|
5
|
+
PLUGIN: "plugin",
|
|
6
|
+
};
|
|
7
|
+
// ── Singleton ──
|
|
8
|
+
let _reporter;
|
|
9
|
+
export function initReporter(opts) {
|
|
10
|
+
if (_reporter)
|
|
11
|
+
return;
|
|
12
|
+
if (!opts.enabled)
|
|
13
|
+
return;
|
|
14
|
+
switch (opts.type) {
|
|
15
|
+
case "local":
|
|
16
|
+
_reporter = new LocalReporter(opts.logger, opts.instanceId, opts.pluginVersion);
|
|
17
|
+
break;
|
|
18
|
+
// TODO: add new reporter type
|
|
19
|
+
default:
|
|
20
|
+
opts.logger.debug?.(`[memory-tdai] Unknown reporter type "${opts.type}", disabled reporting`);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function setReporter(reporter) {
|
|
25
|
+
_reporter = reporter;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reset the reporter singleton so that the next `initReporter` call takes effect.
|
|
29
|
+
* Must be called at plugin re-registration (hot-reload) to pick up config changes.
|
|
30
|
+
*/
|
|
31
|
+
export function resetReporter() {
|
|
32
|
+
_reporter = undefined;
|
|
33
|
+
}
|
|
34
|
+
export function report(event, data) {
|
|
35
|
+
if (!_reporter)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
_reporter.reportFunc(REPORT_CONST.PLUGIN, { event, ...data });
|
|
39
|
+
}
|
|
40
|
+
catch { /* never block business logic */ }
|
|
41
|
+
}
|
|
42
|
+
// ── LocalReporter (default) ──
|
|
43
|
+
class LocalReporter {
|
|
44
|
+
logger;
|
|
45
|
+
instanceId;
|
|
46
|
+
pluginVersion;
|
|
47
|
+
constructor(logger, instanceId, pluginVersion) {
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
this.instanceId = instanceId;
|
|
50
|
+
this.pluginVersion = pluginVersion;
|
|
51
|
+
}
|
|
52
|
+
reportFunc(category, payload) {
|
|
53
|
+
try {
|
|
54
|
+
this.logger.info(JSON.stringify({
|
|
55
|
+
tag: "METRIC",
|
|
56
|
+
category,
|
|
57
|
+
plugin: "memory-tdai",
|
|
58
|
+
instanceId: this.instanceId,
|
|
59
|
+
pluginVersion: this.pluginVersion,
|
|
60
|
+
ts: new Date().toISOString(),
|
|
61
|
+
...payload,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
catch { /* swallow */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ── Instance ID (persisted per-install) ──
|
|
68
|
+
let _instanceIdCache;
|
|
69
|
+
export async function getOrCreateInstanceId(pluginDataDir) {
|
|
70
|
+
if (_instanceIdCache)
|
|
71
|
+
return _instanceIdCache;
|
|
72
|
+
const idFile = path.join(pluginDataDir, ".metadata", "instance_id");
|
|
73
|
+
try {
|
|
74
|
+
const existing = (await fs.readFile(idFile, "utf-8")).trim();
|
|
75
|
+
if (existing) {
|
|
76
|
+
_instanceIdCache = existing;
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* file doesn't exist */ }
|
|
81
|
+
const newId = randomUUID();
|
|
82
|
+
await fs.mkdir(path.dirname(idFile), { recursive: true });
|
|
83
|
+
await fs.writeFile(idFile, newId, "utf-8");
|
|
84
|
+
_instanceIdCache = newId;
|
|
85
|
+
return newId;
|
|
86
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene filename normalizer.
|
|
3
|
+
*
|
|
4
|
+
* Defensive engineering layer that runs *after* the LLM writes scene_blocks/*.md
|
|
5
|
+
* and *before* syncSceneIndex(). Even though the prompt forbids spaces and
|
|
6
|
+
* punctuation in filenames, LLMs occasionally produce names like
|
|
7
|
+
* `Daily Rhythm in Shanghai.md`. Such names break:
|
|
8
|
+
* - Markdown navigation refs that downstream tools parse with `\S+\.md`
|
|
9
|
+
* (e.g. health-checker's scene reference detection).
|
|
10
|
+
* - Shell-based tools that iterate scene files without quoting.
|
|
11
|
+
* - URL/path encoding consumers (COS object keys etc).
|
|
12
|
+
*
|
|
13
|
+
* This module renames offenders to a canonical form on disk and lets every
|
|
14
|
+
* other consumer (PersonaGenerator, recall, profile-sync) read the already
|
|
15
|
+
* sanitized name from scene_index.json — no additional changes needed.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a single scene filename.
|
|
21
|
+
*
|
|
22
|
+
* Rules:
|
|
23
|
+
* - Preserves the `.md` extension (case-insensitive match, lowercased).
|
|
24
|
+
* - Whitespace runs (spaces / tabs) → single hyphen.
|
|
25
|
+
* - Strips quotes, brackets, and ASCII punctuation that breaks shell/markdown.
|
|
26
|
+
* - Collapses consecutive separators (`-`, `_`, `.`).
|
|
27
|
+
* - Trims leading / trailing separators.
|
|
28
|
+
* - Falls back to `"scene"` if the stem becomes empty.
|
|
29
|
+
*
|
|
30
|
+
* Allowed character set after normalization (informally):
|
|
31
|
+
* Unicode letters/numbers, hyphen, underscore, dot.
|
|
32
|
+
*
|
|
33
|
+
* Examples:
|
|
34
|
+
* "Daily Rhythm in Shanghai.md" → "Daily-Rhythm-in-Shanghai.md"
|
|
35
|
+
* "日常生活 健康管理.md" → "日常生活-健康管理.md"
|
|
36
|
+
* "Coffee (Yirgacheffe).md" → "Coffee-Yirgacheffe.md"
|
|
37
|
+
* " spaced .md" → "spaced.md"
|
|
38
|
+
* ".MD" → "scene.md"
|
|
39
|
+
* "已经规范.md" → "已经规范.md" (no-op)
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeSceneFilename(name) {
|
|
42
|
+
if (!name)
|
|
43
|
+
return "scene.md";
|
|
44
|
+
// Strip directory components defensively — we only normalize the basename.
|
|
45
|
+
const base = name.replace(/^.*[\\/]/, "");
|
|
46
|
+
// Detect & strip `.md` (case-insensitive). Always re-emit lowercase `.md`.
|
|
47
|
+
const lower = base.toLowerCase();
|
|
48
|
+
const hasMd = lower.endsWith(".md");
|
|
49
|
+
const stem = hasMd ? base.slice(0, -3) : base;
|
|
50
|
+
const safe = stem
|
|
51
|
+
// Replace whitespace runs (incl. NBSP, full-width space) with `-`
|
|
52
|
+
.replace(/[\s\u00A0\u3000]+/g, "-")
|
|
53
|
+
// Drop quotes, brackets, and punctuation known to break shells/markdown.
|
|
54
|
+
// Keep Unicode letters/numbers and the safe separators `-`, `_`, `.`.
|
|
55
|
+
.replace(/[()[\]{}<>'"`,;:!?*|/\\=&%$#@^~+]/g, "")
|
|
56
|
+
// Collapse consecutive separators.
|
|
57
|
+
.replace(/-{2,}/g, "-")
|
|
58
|
+
.replace(/_{2,}/g, "_")
|
|
59
|
+
.replace(/\.{2,}/g, ".")
|
|
60
|
+
// Trim leading / trailing separators.
|
|
61
|
+
.replace(/^[-_.]+|[-_.]+$/g, "");
|
|
62
|
+
return (safe || "scene") + ".md";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Return whether a filename already matches its normalized form.
|
|
66
|
+
* Faster than computing the normalized form when callers only need a yes/no.
|
|
67
|
+
*/
|
|
68
|
+
export function isNormalizedSceneFilename(name) {
|
|
69
|
+
return normalizeSceneFilename(name) === name;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a non-conflicting target path inside `dir` for the desired filename.
|
|
73
|
+
*
|
|
74
|
+
* If `desired` (e.g. `Daily-Rhythm.md`) already exists in `dir`, append a
|
|
75
|
+
* numeric suffix `-2`, `-3`, ... before the `.md` extension until a free slot
|
|
76
|
+
* is found. Caller may also pass `excludePath` to ignore a known existing file
|
|
77
|
+
* (e.g. the source path of an in-flight rename, when source != target).
|
|
78
|
+
*/
|
|
79
|
+
export async function resolveUniqueScenePath(dir, desired, excludePath) {
|
|
80
|
+
const target = path.join(dir, desired);
|
|
81
|
+
if (!(await pathExists(target)) || target === excludePath)
|
|
82
|
+
return target;
|
|
83
|
+
const ext = ".md";
|
|
84
|
+
const stem = desired.endsWith(ext) ? desired.slice(0, -ext.length) : desired;
|
|
85
|
+
// Bound the search to keep this defensive (LLMs rarely produce hundreds of
|
|
86
|
+
// colliding names; if they do, surface the failure rather than spin).
|
|
87
|
+
for (let i = 2; i < 1000; i++) {
|
|
88
|
+
const candidate = path.join(dir, `${stem}-${i}${ext}`);
|
|
89
|
+
if (!(await pathExists(candidate)) || candidate === excludePath) {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`resolveUniqueScenePath: could not find a free slot for ${desired} in ${dir} after 1000 attempts`);
|
|
94
|
+
}
|
|
95
|
+
async function pathExists(p) {
|
|
96
|
+
try {
|
|
97
|
+
await fs.access(p);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Walk a scene_blocks directory and rename any `.md` file whose basename does
|
|
106
|
+
* not match `normalizeSceneFilename(basename)`.
|
|
107
|
+
*
|
|
108
|
+
* Safe to call multiple times: subsequent invocations are no-ops once names
|
|
109
|
+
* have stabilized.
|
|
110
|
+
*
|
|
111
|
+
* Notes:
|
|
112
|
+
* - Non-`.md` files are ignored (the LLM tool surface is restricted to .md,
|
|
113
|
+
* but the directory may contain transient artifacts).
|
|
114
|
+
* - Empty / soft-deleted files are not pre-filtered here; the SceneExtractor
|
|
115
|
+
* cleanup pass handles those before / after this call as appropriate.
|
|
116
|
+
* - Failures on individual entries are logged via the optional logger and
|
|
117
|
+
* do not abort the loop — index sync should still see the remaining files.
|
|
118
|
+
*/
|
|
119
|
+
export async function normalizeSceneFilenames(blocksDir, logger) {
|
|
120
|
+
const result = { renamed: 0, skipped: 0, renames: [] };
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = (await fs.readdir(blocksDir)).filter((f) => f.endsWith(".md"));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
for (const file of entries) {
|
|
129
|
+
const normalized = normalizeSceneFilename(file);
|
|
130
|
+
if (normalized === file) {
|
|
131
|
+
result.skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const from = path.join(blocksDir, file);
|
|
135
|
+
let to;
|
|
136
|
+
try {
|
|
137
|
+
to = await resolveUniqueScenePath(blocksDir, normalized, from);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
logger?.warn?.(`[filename-normalizer] could not resolve unique target for ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
result.skipped++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (to === from) {
|
|
145
|
+
// Filesystem already matched (e.g. case-insensitive FS where source and
|
|
146
|
+
// target collapse to the same inode); treat as a no-op.
|
|
147
|
+
result.skipped++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await fs.rename(from, to);
|
|
152
|
+
result.renamed++;
|
|
153
|
+
result.renames.push({ from: file, to: path.basename(to) });
|
|
154
|
+
logger?.debug?.(`[filename-normalizer] renamed: ${file} → ${path.basename(to)}`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
logger?.warn?.(`[filename-normalizer] rename failed (${file} → ${path.basename(to)}): ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|