march-cli 0.1.42 → 0.1.45
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/package.json +1 -1
- package/src/agent/code-search/cache.mjs +3 -2
- package/src/agent/code-search/engine.mjs +2 -0
- package/src/agent/code-search/retrieval/resilient-vectorizer.mjs +59 -0
- package/src/agent/code-search/tool.mjs +11 -5
- package/src/agent/runtime/remote-ui-client.mjs +1 -1
- package/src/agent/runtime/ui-event-bridge.mjs +2 -2
- package/src/agent/turn/turn-runner.mjs +23 -16
- package/src/cli/fallback-ui.mjs +2 -2
- package/src/cli/repl-loop.mjs +7 -7
- package/src/cli/startup/app-runtime.mjs +5 -2
- package/src/cli/tui/output/timeline-block-restore.mjs +1 -1
- package/src/cli/tui/recall-rendering.mjs +14 -7
- package/src/cli/turn/turn-input-preparer.mjs +6 -4
- package/src/cli/ui.mjs +2 -2
- package/src/cli/workspace/tui-timeline-projection.mjs +1 -1
- package/src/context/engine.mjs +2 -2
- package/src/context/system-core/base.md +1 -1
- package/src/memory/markdown/markdown-format.mjs +0 -17
- package/src/memory/markdown/markdown-recall.mjs +11 -19
- package/src/memory/markdown/semantic-preload.mjs +17 -0
- package/src/memory/markdown/semantic-recall.mjs +165 -0
- package/src/memory/markdown/sqlite-index.mjs +1 -13
- package/src/memory/markdown-store.mjs +24 -52
- package/src/web-ui/dist/assets/{index-DrlJis_D.js → index-CBYbNVgs.js} +1 -1
- package/src/web-ui/dist/assets/{index-BQtl1uQs.css → index-CcbYCcWs.css} +1 -1
- package/src/web-ui/dist/index.html +2 -2
- package/src/web-ui/runtime-host.mjs +5 -2
- package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +24 -0
- package/src/web-ui/src/model.ts +18 -0
- package/src/web-ui/src/runtime/client.ts +2 -1
- package/src/web-ui/src/runtime/runtimeTimeline.ts +5 -0
- package/src/web-ui/src/styles/shell.css +6 -0
- package/src/web-ui/src/timelineAdapter.ts +2 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Model2VecVectorizer } from "../../agent/code-search/retrieval/model2vec.mjs";
|
|
4
|
+
import { ResilientVectorizer } from "../../agent/code-search/retrieval/resilient-vectorizer.mjs";
|
|
5
|
+
import { parseMemoryMarkdown } from "./markdown-format.mjs";
|
|
6
|
+
|
|
7
|
+
export const POTION_RETRIEVAL_MODEL_ID = "minishlab/potion-retrieval-32M";
|
|
8
|
+
|
|
9
|
+
const MAX_CHUNK_CHARS = 1800;
|
|
10
|
+
export const DEFAULT_MEMORY_RECALL_MIN_SCORE = 0.3;
|
|
11
|
+
|
|
12
|
+
export class SemanticMemoryRecallIndex {
|
|
13
|
+
constructor({ stateRoot = null, modelId = POTION_RETRIEVAL_MODEL_ID, modelDir = null, vectorizer = null, minScore = parseMemoryRecallMinScore() } = {}) {
|
|
14
|
+
this.modelId = modelId;
|
|
15
|
+
this.minScore = minScore;
|
|
16
|
+
this.vectorizer = vectorizer ?? createDefaultVectorizer({ stateRoot, modelId, modelDir });
|
|
17
|
+
this.signature = "";
|
|
18
|
+
this.chunks = [];
|
|
19
|
+
this.vectors = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get enabled() {
|
|
23
|
+
return Boolean(this.vectorizer);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get warning() {
|
|
27
|
+
return this.vectorizer?.warning ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get status() {
|
|
31
|
+
return this.vectorizer?.status ?? "primary";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async preload() {
|
|
35
|
+
if (!this.vectorizer) return false;
|
|
36
|
+
if (typeof this.vectorizer.load === "function") await this.vectorizer.load();
|
|
37
|
+
else await this.vectorizer.encode(["memory recall warmup"]);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async search(query, { entries, excluded = new Set(), limit = 3, candidateLimit = 5 } = {}) {
|
|
42
|
+
const empty = { recalled: [], candidates: [], threshold: this.minScore };
|
|
43
|
+
if (!this.vectorizer || !String(query ?? "").trim()) return empty;
|
|
44
|
+
const activeEntries = [...entries.values()].filter((entry) => entry.status === "active" && entry.description && !excluded.has(entry.id));
|
|
45
|
+
if (activeEntries.length === 0) return empty;
|
|
46
|
+
await this.#ensureIndex(activeEntries);
|
|
47
|
+
const [queryVector] = await this.vectorizer.encode([query]);
|
|
48
|
+
if (!queryVector || queryVector.norm === 0) return empty;
|
|
49
|
+
|
|
50
|
+
const bestByEntry = new Map();
|
|
51
|
+
for (let index = 0; index < this.vectors.length; index += 1) {
|
|
52
|
+
const chunk = this.chunks[index];
|
|
53
|
+
if (excluded.has(chunk.entry.id)) continue;
|
|
54
|
+
const score = cosineSimilarity(queryVector, this.vectors[index]);
|
|
55
|
+
const prev = bestByEntry.get(chunk.entry.id);
|
|
56
|
+
if (!prev || score > prev.score) bestByEntry.set(chunk.entry.id, { entry: chunk.entry, score });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const candidates = [...bestByEntry.values()]
|
|
60
|
+
.filter(({ score }) => Number.isFinite(score) && score > 0)
|
|
61
|
+
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
|
62
|
+
.map(({ entry, score }) => ({ entry, score, recalled: score >= this.minScore }));
|
|
63
|
+
return {
|
|
64
|
+
recalled: candidates.filter((candidate) => candidate.recalled).slice(0, limit),
|
|
65
|
+
candidates: candidates.slice(0, Math.max(limit, candidateLimit)),
|
|
66
|
+
threshold: this.minScore,
|
|
67
|
+
vectorizerStatus: this.status,
|
|
68
|
+
warning: this.warning,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #ensureIndex(entries) {
|
|
73
|
+
const signature = entries.map(entrySignature).join("\n");
|
|
74
|
+
if (signature === this.signature) return;
|
|
75
|
+
this.chunks = entries.flatMap(memoryChunks);
|
|
76
|
+
this.vectors = this.chunks.length > 0
|
|
77
|
+
? await this.vectorizer.encode(this.chunks.map((chunk) => chunk.text))
|
|
78
|
+
: [];
|
|
79
|
+
this.signature = signature;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseMemoryRecallMinScore(value = process.env.MARCH_MEMORY_RECALL_MIN_SCORE) {
|
|
84
|
+
if (value == null || value === "") return DEFAULT_MEMORY_RECALL_MIN_SCORE;
|
|
85
|
+
const normalized = String(value).trim().toLowerCase();
|
|
86
|
+
if (["false", "no", "off"].includes(normalized)) return 0;
|
|
87
|
+
const parsed = Number(normalized);
|
|
88
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MEMORY_RECALL_MIN_SCORE;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createDefaultVectorizer({ stateRoot, modelId, modelDir }) {
|
|
92
|
+
const dir = modelDir ?? (stateRoot ? join(stateRoot, "memory", "models", modelId.replaceAll("/", "__")) : null);
|
|
93
|
+
if (!dir) return null;
|
|
94
|
+
return new ResilientVectorizer({
|
|
95
|
+
primary: new Model2VecVectorizer({ modelDir: dir, modelId }),
|
|
96
|
+
label: "memory recall",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function memoryChunks(entry) {
|
|
101
|
+
const body = readMemoryBody(entry);
|
|
102
|
+
const sections = splitMarkdownBody(body);
|
|
103
|
+
const chunks = sections.length > 0 ? sections : [""];
|
|
104
|
+
return chunks.map((section, index) => ({
|
|
105
|
+
entry,
|
|
106
|
+
index,
|
|
107
|
+
text: [
|
|
108
|
+
entry.name,
|
|
109
|
+
entry.description,
|
|
110
|
+
entry.tags.join(" "),
|
|
111
|
+
section,
|
|
112
|
+
].filter(Boolean).join("\n"),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readMemoryBody(entry) {
|
|
117
|
+
try {
|
|
118
|
+
return parseMemoryMarkdown(readFileSync(entry.path, "utf8")).body.trim();
|
|
119
|
+
} catch {
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function splitMarkdownBody(body) {
|
|
125
|
+
const blocks = body
|
|
126
|
+
.split(/\n{2,}/)
|
|
127
|
+
.map((block) => block.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let current = "";
|
|
131
|
+
for (const block of blocks) {
|
|
132
|
+
if (!current) {
|
|
133
|
+
current = block;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (current.length + block.length + 2 <= MAX_CHUNK_CHARS) {
|
|
137
|
+
current = `${current}\n\n${block}`;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
chunks.push(current);
|
|
141
|
+
current = block;
|
|
142
|
+
}
|
|
143
|
+
if (current) chunks.push(current);
|
|
144
|
+
return chunks.flatMap(splitOversizedChunk);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function splitOversizedChunk(text) {
|
|
148
|
+
if (text.length <= MAX_CHUNK_CHARS) return [text];
|
|
149
|
+
const chunks = [];
|
|
150
|
+
for (let index = 0; index < text.length; index += MAX_CHUNK_CHARS) {
|
|
151
|
+
chunks.push(text.slice(index, index + MAX_CHUNK_CHARS));
|
|
152
|
+
}
|
|
153
|
+
return chunks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function entrySignature(entry) {
|
|
157
|
+
return `${entry.id}:${entry.path}:${Math.trunc(entry.mtimeMs ?? 0)}:${entry.size ?? 0}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function cosineSimilarity(left, right) {
|
|
161
|
+
if (!left?.norm || !right?.norm) return 0;
|
|
162
|
+
let dot = 0;
|
|
163
|
+
for (let index = 0; index < left.values.length; index += 1) dot += left.values[index] * right.values[index];
|
|
164
|
+
return dot / (left.norm * right.norm);
|
|
165
|
+
}
|
|
@@ -15,11 +15,6 @@ CREATE TABLE IF NOT EXISTS memory_index (
|
|
|
15
15
|
mtime_ms REAL NOT NULL,
|
|
16
16
|
size INTEGER NOT NULL
|
|
17
17
|
);
|
|
18
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS memory_tags_fts USING fts5(
|
|
19
|
-
id UNINDEXED,
|
|
20
|
-
tags_text,
|
|
21
|
-
tokenize = 'unicode61'
|
|
22
|
-
);
|
|
23
18
|
`;
|
|
24
19
|
|
|
25
20
|
export function openMarkdownMemoryIndex(path) {
|
|
@@ -33,7 +28,6 @@ export function openMarkdownMemoryIndex(path) {
|
|
|
33
28
|
|
|
34
29
|
export function clearMarkdownMemoryIndex(db) {
|
|
35
30
|
db.exec("DELETE FROM memory_index");
|
|
36
|
-
db.exec("DELETE FROM memory_tags_fts");
|
|
37
31
|
}
|
|
38
32
|
|
|
39
33
|
export function loadMarkdownMemoryIndex(db) {
|
|
@@ -48,17 +42,15 @@ export function loadMarkdownMemoryIndex(db) {
|
|
|
48
42
|
return { entries, pathStats };
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
export function replaceMarkdownMemoryIndex(db, entries
|
|
45
|
+
export function replaceMarkdownMemoryIndex(db, entries) {
|
|
52
46
|
db.exec("BEGIN IMMEDIATE");
|
|
53
47
|
try {
|
|
54
48
|
clearMarkdownMemoryIndex(db);
|
|
55
49
|
const insertMeta = db.prepare(
|
|
56
50
|
"INSERT INTO memory_index (id, path, name, description, tags_json, status, created_at, updated_at, mtime_ms, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
57
51
|
);
|
|
58
|
-
const insertFts = db.prepare("INSERT INTO memory_tags_fts (id, tags_text) VALUES (?, ?)");
|
|
59
52
|
for (const entry of entries.values()) {
|
|
60
53
|
insertMeta.run(entry.id, entry.path, entry.name, entry.description, JSON.stringify(entry.tags), entry.status, entry.createdAt, entry.updatedAt, entry.mtimeMs, entry.size);
|
|
61
|
-
insertFts.run(entry.id, expandTags(entry.tags).join(" "));
|
|
62
54
|
}
|
|
63
55
|
db.exec("COMMIT");
|
|
64
56
|
} catch (err) {
|
|
@@ -67,10 +59,6 @@ export function replaceMarkdownMemoryIndex(db, entries, expandTags) {
|
|
|
67
59
|
}
|
|
68
60
|
}
|
|
69
61
|
|
|
70
|
-
export function queryMarkdownMemoryIndex(db, query) {
|
|
71
|
-
return db.prepare("SELECT id FROM memory_tags_fts WHERE tags_text MATCH ? LIMIT 50").all(query);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
62
|
function rowToEntry(row) {
|
|
75
63
|
return {
|
|
76
64
|
id: String(row.id),
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
expandTags,
|
|
5
4
|
formatMemoryMarkdown,
|
|
6
5
|
generateMemoryId,
|
|
7
6
|
normalizeTags,
|
|
8
|
-
normalizeText,
|
|
9
7
|
parseMemoryMarkdown,
|
|
10
|
-
quoteFtsTerm,
|
|
11
8
|
walkMarkdownFiles,
|
|
12
9
|
} from "./markdown/markdown-format.mjs";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
10
|
+
import { toHint } from "./markdown/markdown-recall.mjs";
|
|
11
|
+
import { SemanticMemoryRecallIndex } from "./markdown/semantic-recall.mjs";
|
|
12
|
+
import { clearMarkdownMemoryIndex, loadMarkdownMemoryIndex, openMarkdownMemoryIndex, replaceMarkdownMemoryIndex } from "./markdown/sqlite-index.mjs";
|
|
15
13
|
import { softDeleteMemoryFile } from "./markdown/markdown-delete.mjs";
|
|
16
14
|
import { isMemoryIdLike, isSingleEditAway } from "./markdown/memory-id.mjs";
|
|
17
15
|
import { openMarkdownRoot, searchMarkdownRoot } from "./search.mjs";
|
|
@@ -22,15 +20,17 @@ export { normalizeTags } from "./markdown/markdown-format.mjs";
|
|
|
22
20
|
const DEFAULT_SCAN_INTERVAL_MS = 5000;
|
|
23
21
|
|
|
24
22
|
export class MarkdownMemoryStore {
|
|
25
|
-
constructor({ root, now = () => new Date(), indexPath = null } = {}) {
|
|
23
|
+
constructor({ root, now = () => new Date(), indexPath = null, stateRoot = null, semanticRecall = true, semanticVectorizer = null, semanticModelId = undefined, semanticModelDir = null, semanticMinScore = undefined } = {}) {
|
|
26
24
|
if (!root) throw new Error("MarkdownMemoryStore requires a root path");
|
|
27
25
|
this.root = resolve(root);
|
|
28
26
|
this.now = now;
|
|
27
|
+
this.semanticRecall = semanticRecall ? new SemanticMemoryRecallIndex({ stateRoot, modelId: semanticModelId, modelDir: semanticModelDir, vectorizer: semanticVectorizer, minScore: semanticMinScore }) : null;
|
|
28
|
+
this.semanticRecallWarning = null;
|
|
29
|
+
this.lastUserRecallReport = null;
|
|
29
30
|
this.indexPath = indexPath ? resolve(indexPath) : join(this.root, ".march-memory-index.sqlite");
|
|
30
31
|
this.db = openMarkdownMemoryIndex(this.indexPath);
|
|
31
32
|
this.entries = new Map();
|
|
32
33
|
this.pathStats = new Map();
|
|
33
|
-
this.tagDictionary = new Set();
|
|
34
34
|
this.diagnostics = [];
|
|
35
35
|
this.lastScanAt = 0;
|
|
36
36
|
this.scanIntervalMs = DEFAULT_SCAN_INTERVAL_MS;
|
|
@@ -99,8 +99,7 @@ export class MarkdownMemoryStore {
|
|
|
99
99
|
this.entries = nextEntries;
|
|
100
100
|
this.pathStats = nextStats;
|
|
101
101
|
this.diagnostics = diagnostics;
|
|
102
|
-
this
|
|
103
|
-
replaceMarkdownMemoryIndex(this.db, this.entries, expandTags);
|
|
102
|
+
replaceMarkdownMemoryIndex(this.db, this.entries);
|
|
104
103
|
this.lastScanAt = Date.now();
|
|
105
104
|
return { entries: this.entries.size, diagnostics };
|
|
106
105
|
}
|
|
@@ -121,18 +120,16 @@ export class MarkdownMemoryStore {
|
|
|
121
120
|
this.db.close?.();
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
recallForUser(text, { limit = 3,
|
|
123
|
+
async recallForUser(text, { limit = 3, excludedIds = [] } = {}) {
|
|
125
124
|
const excluded = new Set([...excludedIds, ...this.turnSeenMemoryIds]);
|
|
126
|
-
const hints = this.#
|
|
127
|
-
for (const hint of hints)
|
|
128
|
-
this.turnSeenMemoryIds.add(hint.id);
|
|
129
|
-
}
|
|
125
|
+
const hints = await this.#recallSemantic(text, { limit, excluded });
|
|
126
|
+
for (const hint of hints) this.turnSeenMemoryIds.add(hint.id);
|
|
130
127
|
return hints;
|
|
131
128
|
}
|
|
132
129
|
|
|
133
|
-
recallForAssistant(text, { limit = 2,
|
|
130
|
+
async recallForAssistant(text, { limit = 2, excludedIds = [] } = {}) {
|
|
134
131
|
const excluded = new Set([...excludedIds, ...this.turnSeenMemoryIds]);
|
|
135
|
-
const hints = this.#
|
|
132
|
+
const hints = await this.#recallSemantic(text, { limit, excluded, recordReport: false });
|
|
136
133
|
for (const hint of hints) this.turnSeenMemoryIds.add(hint.id);
|
|
137
134
|
return hints;
|
|
138
135
|
}
|
|
@@ -205,46 +202,21 @@ export class MarkdownMemoryStore {
|
|
|
205
202
|
return result;
|
|
206
203
|
}
|
|
207
204
|
|
|
208
|
-
#
|
|
205
|
+
async #recallSemantic(text, { limit, excluded, recordReport = true }) {
|
|
209
206
|
this.ensureFresh();
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
const query = queryTerms.map(quoteFtsTerm).join(" OR ");
|
|
213
|
-
let rows = [];
|
|
207
|
+
if (recordReport) this.lastUserRecallReport = null;
|
|
208
|
+
if (!this.semanticRecall?.enabled) return [];
|
|
214
209
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
210
|
+
const result = await this.semanticRecall.search(text, { entries: this.entries, excluded, limit });
|
|
211
|
+
const hints = result.recalled.map(({ entry, score }) => toHint(entry, { score }));
|
|
212
|
+
if (recordReport) {
|
|
213
|
+
this.lastUserRecallReport = { threshold: result.threshold, vectorizerStatus: result.vectorizerStatus, warning: result.warning, hints, candidates: result.candidates.map(({ entry, score, recalled }) => ({ ...toHint(entry, { score }), recalled })) };
|
|
214
|
+
}
|
|
215
|
+
return hints;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.semanticRecallWarning = err?.message ?? String(err);
|
|
217
218
|
return [];
|
|
218
219
|
}
|
|
219
|
-
const scored = [];
|
|
220
|
-
for (const row of rows) {
|
|
221
|
-
if (excluded.has(row.id)) continue;
|
|
222
|
-
const entry = this.entries.get(row.id);
|
|
223
|
-
if (!entry || entry.status !== "active" || !entry.description) continue;
|
|
224
|
-
const score = scoreEntry(entry, queryTerms, currentProject);
|
|
225
|
-
if (score <= 0) continue;
|
|
226
|
-
scored.push({ score, entry });
|
|
227
|
-
}
|
|
228
|
-
scored.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name));
|
|
229
|
-
return scored.slice(0, limit).map(({ entry }) => toHint(entry));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
#extractKnownTagTerms(text) {
|
|
233
|
-
const normalized = normalizeText(text);
|
|
234
|
-
if (!normalized) return [];
|
|
235
|
-
const terms = [];
|
|
236
|
-
for (const term of this.tagDictionary) {
|
|
237
|
-
if (term.length < 2) continue;
|
|
238
|
-
if (normalized.includes(term)) terms.push(term);
|
|
239
|
-
}
|
|
240
|
-
return [...new Set(terms)].sort((a, b) => b.length - a.length).slice(0, 16);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
#rebuildTagDictionary() {
|
|
244
|
-
this.tagDictionary = new Set();
|
|
245
|
-
for (const entry of this.entries.values()) {
|
|
246
|
-
for (const term of expandTags(entry.tags)) this.tagDictionary.add(normalizeText(term));
|
|
247
|
-
}
|
|
248
220
|
}
|
|
249
221
|
|
|
250
222
|
#newMemoryPath(isoDate, id) {
|