mlx-bun 0.0.5 → 0.0.6

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.
Files changed (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
@@ -1,17 +1,15 @@
1
- // MMLU 5-shot — multi-domain multiple-choice knowledge. Port of
2
- // optiq/eval/mmlu.py: per-subject dev exemplars, A/B/C/D format, greedy
3
- // decode, extract the predicted answer letter, compare to the gold index.
1
+ // MMLU 5-shot — multi-domain multiple-choice knowledge. Faithful port of
2
+ // optiq/eval/mmlu.py: per-subject dev exemplars, A/B/C/D format, and scoring by
3
+ // **last-token logit argmax over the " A"/" B"/" C"/" D" tokens** on the RAW
4
+ // 5-shot prompt — NO chat template, NO generation. This is the standard
5
+ // (Hendrycks/Unsloth) MMLU methodology; the earlier chat-wrap+generate variant
6
+ // scored ~5 pts low vs optiq's published number, so we match optiq exactly.
4
7
  //
5
- // The Python reference scores via first-letter logit argmax (no generation).
6
- // The mlx-bun harness routes everything through generateText() (decoded
7
- // completion). For an instruct model the faithful argmax-on-raw-text path
8
- // underscores on a small noisy sample, so we drive the chat template: the
9
- // 5-shot exemplars become the priming context and an explicit instruction
10
- // asks for ONLY the single letter A/B/C/D. Extraction is robust to chatty
11
- // formatting (</think> wrappers, "Answer: X", "(X)", "**X**", bare letter).
12
- // Prompt format, header, exemplar selection, and gold mapping are verbatim.
8
+ // Questions: by DEFAULT we score optiq's EXACT frozen draw (mmlu_optiq_frozen, the
9
+ // same ~969 his published 52.4 used) so base/adapter are directly comparable to his
10
+ // table. MLX_BUN_MMLU_FROZEN=0 falls back to our own sampling of the full 14k set.
13
11
 
14
- import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
12
+ import { loadJsonl, sampleIndices, type TaskModel } from "../runner";
15
13
 
16
14
  interface MmluRow {
17
15
  question: string;
@@ -42,12 +40,8 @@ function formatExample(question: string, choices: string[], answerIdx?: number):
42
40
  return s;
43
41
  }
44
42
 
45
- /** 5-shot prompt: subject header → n demos → open test question.
46
- *
47
- * Built for the chat template: the header + worked exemplars prime the model,
48
- * and a trailing instruction pins the output to a single bare letter. The
49
- * exemplars still close their "Answer: X" line so the model sees the expected
50
- * shape, and the final question is left open ("Answer:") for the model. */
43
+ /** 5-shot prompt (verbatim optiq _build_prompt): subject header → n demos →
44
+ * open test question ending in "Answer:". Fed raw (no chat template). */
51
45
  function buildPrompt(row: MmluRow, devExamples: MmluRow[], nShots: number): string {
52
46
  let prompt = `The following are multiple choice questions (with answers) about ${formatSubject(row.subject)}.\n\n`;
53
47
  for (const ex of devExamples.slice(0, nShots)) {
@@ -55,48 +49,46 @@ function buildPrompt(row: MmluRow, devExamples: MmluRow[], nShots: number): stri
55
49
  prompt += "\n";
56
50
  }
57
51
  prompt += formatExample(row.question, row.choices);
58
- prompt += "\n\nRespond with ONLY the single letter (A, B, C, or D) of the correct answer. Do not explain.";
59
52
  return prompt;
60
53
  }
61
54
 
62
- /** Model output → predicted letter index 0..3, else null. Robust to chatty
63
- * instruct formatting: strips a leading <think>…</think> block, then prefers
64
- * an explicit "Answer: X" / "(X)" / "**X**" marker, falling back to the first
65
- * standalone A/B/C/D token (one not glued inside a longer word). */
66
- export function extractAnswer(textIn: string): number | null {
67
- let text = textIn;
68
- if (text.includes("</think>")) text = text.split("</think>").pop() ?? text;
69
-
70
- const toIdx = (ch: string): number => LETTERS.indexOf(ch.toUpperCase() as (typeof LETTERS)[number]);
71
-
72
- // Explicit answer markers, in priority order.
73
- const markers = [
74
- /(?:answer|correct answer|the answer is)\s*(?:is|:)?\s*\(?\*{0,2}([A-Da-d])\*{0,2}\)?/i,
75
- /\*\*\s*([A-Da-d])\s*\*\*/, // **X**
76
- /\(\s*([A-Da-d])\s*\)/, // (X)
77
- ];
78
- for (const re of markers) {
79
- const m = text.match(re);
80
- if (m) return toIdx(m[1]!);
55
+ /** Forward the prompt (prefill + last token) and return the index 0..3 of the
56
+ * highest-logit answer letter — optiq's argmax-on-last-token scoring. */
57
+ function lastTokenArgmax(model: TaskModel["model"], ids: number[], letterIds: number[]): number {
58
+ const cache = model.makeCache();
59
+ try {
60
+ if (ids.length > 1) model.forward(ids.slice(0, -1), cache).dispose(); // prefill (cache only)
61
+ const logits = model.forward([ids[ids.length - 1]!], cache); // [1, 1, V]
62
+ const v = logits.toFloat32();
63
+ logits.dispose();
64
+ let best = 0, bestVal = -Infinity;
65
+ for (let i = 0; i < letterIds.length; i++) {
66
+ const val = v[letterIds[i]!]!;
67
+ if (val > bestVal) { bestVal = val; best = i; }
68
+ }
69
+ return best;
70
+ } finally {
71
+ for (const c of cache) c.dispose();
81
72
  }
82
-
83
- // Fallback: first standalone letter (word-boundary, not embedded in a word).
84
- const standalone = text.match(/\b([A-D])\b/);
85
- if (standalone) return toIdx(standalone[1]!);
86
-
87
- return null;
88
73
  }
89
74
 
90
75
  export async function evaluateMmlu(
91
76
  tm: TaskModel,
92
- opts: { nSamples?: number; nShots?: number; maxTokens?: number; seed?: number } = {},
77
+ opts: { nSamples?: number; nShots?: number; seed?: number; frozen?: boolean } = {},
93
78
  ): Promise<MmluResult> {
94
79
  const nShots = opts.nShots ?? 5;
95
- const maxTokens = opts.maxTokens ?? 16;
96
80
  const seed = opts.seed ?? 42;
97
81
 
98
- const testRows = loadJsonl<MmluRow>("mmlu_test");
99
- const devRows = loadJsonl<MmluRow>("mmlu_dev");
82
+ // Optiq-parity mode (DEFAULT): score optiq's EXACT frozen draw — the same ~969
83
+ // questions + dev exemplars his published table used — so base/adapter numbers are
84
+ // DIRECTLY comparable to optiq's. Any remaining gap is then pure forward-logit
85
+ // parity, not sampling. Set MLX_BUN_MMLU_FROZEN=0 (or frozen:false) to fall back to
86
+ // our own stratified sampling of the full 14k test set (a DIFFERENT subset: JS RNG
87
+ // ≠ numpy RandomState, so it does not match optiq's questions even at the same seed).
88
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_MMLU_FROZEN !== "0");
89
+
90
+ const testRows = loadJsonl<MmluRow>(useFrozen ? "mmlu_optiq_frozen" : "mmlu_test");
91
+ const devRows = loadJsonl<MmluRow>(useFrozen ? "mmlu_optiq_dev" : "mmlu_dev");
100
92
 
101
93
  // Per-subject dev exemplars for prompt construction.
102
94
  const devBySubject = new Map<string, MmluRow[]>();
@@ -106,38 +98,57 @@ export async function evaluateMmlu(
106
98
  devBySubject.set(ex.subject, list);
107
99
  }
108
100
 
109
- // Stratified sample: roughly equal questions per subject (matches mmlu.py),
110
- // deterministic via the shared seeded subsampler.
111
- const testBySubject = new Map<string, number[]>();
112
- for (let i = 0; i < testRows.length; i++) {
113
- const subj = testRows[i]!.subject;
114
- const list = testBySubject.get(subj) ?? [];
115
- list.push(i);
116
- testBySubject.set(subj, list);
117
- }
118
- const subjects = [...testBySubject.keys()].sort();
119
- const nSamples = opts.nSamples ?? 1000;
120
- const perSubject = Math.max(1, Math.floor(nSamples / subjects.length));
121
-
122
- let sampled: number[] = [];
123
- for (const subj of subjects) {
124
- const pool = testBySubject.get(subj)!;
125
- const take = Math.min(perSubject, pool.length);
126
- for (const j of sampleIndices(pool.length, take, seed)) sampled.push(pool[j]!);
127
- }
128
- if (sampled.length > nSamples) {
129
- const keep = sampleIndices(sampled.length, nSamples, seed);
130
- sampled = keep.map((k) => sampled[k]!);
101
+ let sampled: number[];
102
+ if (useFrozen) {
103
+ // Already optiq's exact draw — score ALL of it, in his order, NO resampling
104
+ // (re-sampling here would re-introduce exactly the divergence we're removing).
105
+ sampled = testRows.map((_, i) => i);
106
+ } else {
107
+ // Stratified sample: roughly equal questions per subject (mirrors mmlu.py).
108
+ const testBySubject = new Map<string, number[]>();
109
+ for (let i = 0; i < testRows.length; i++) {
110
+ const subj = testRows[i]!.subject;
111
+ const list = testBySubject.get(subj) ?? [];
112
+ list.push(i);
113
+ testBySubject.set(subj, list);
114
+ }
115
+ const subjects = [...testBySubject.keys()].sort();
116
+ const nSamples = opts.nSamples ?? 1000;
117
+ const perSubject = Math.max(1, Math.floor(nSamples / subjects.length));
118
+ sampled = [];
119
+ for (const subj of subjects) {
120
+ const pool = testBySubject.get(subj)!;
121
+ const take = Math.min(perSubject, pool.length);
122
+ for (const j of sampleIndices(pool.length, take, seed)) sampled.push(pool[j]!);
123
+ }
124
+ if (sampled.length > nSamples) {
125
+ const keep = sampleIndices(sampled.length, nSamples, seed);
126
+ sampled = keep.map((k) => sampled[k]!);
127
+ }
131
128
  }
132
129
 
130
+ // Answer-letter token ids — " A".." D" first token, matching how Hendrycks
131
+ // scores the token after "Answer:". Strip a leading BOS (≈ add_special_tokens=False).
132
+ const bos = tm.tokenizer.bosTokenId;
133
+ const stripBos = (ids: number[]) => (bos !== null && ids[0] === bos ? ids.slice(1) : ids);
134
+ const letterIds = LETTERS.map((L) => {
135
+ const ids = stripBos(tm.tokenizer.encode(` ${L}`));
136
+ return ids[0]!;
137
+ });
138
+
139
+ // Re-assert the mounted adapter for our direct forward()s: generation tasks reset
140
+ // loraState.active to [] when they finish, and MMLU bypasses generate() entirely,
141
+ // so without this the argmax would silently score BASE even with --adapter set.
142
+ if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters;
143
+
133
144
  let nCorrect = 0;
134
145
  for (let k = 0; k < sampled.length; k++) {
135
146
  const item = testRows[sampled[k]!]!;
136
147
  const devEx = devBySubject.get(item.subject) ?? [];
137
- const out = await generateText(tm, buildPrompt(item, devEx, nShots), { maxTokens, useChat: true });
138
- const pred = extractAnswer(out);
139
- if (pred !== null && pred === item.answer) nCorrect++;
140
- if ((k + 1) % 10 === 0 || k + 1 === sampled.length)
148
+ const ids = stripBos(tm.tokenizer.encode(buildPrompt(item, devEx, nShots)));
149
+ const pred = lastTokenArgmax(tm.model, ids, letterIds);
150
+ if (pred === item.answer) nCorrect++;
151
+ if ((k + 1) % 25 === 0 || k + 1 === sampled.length)
141
152
  process.stderr.write(`\r mmlu ${k + 1}/${sampled.length} acc=${((nCorrect / (k + 1)) * 100).toFixed(1)}%`);
142
153
  }
143
154
  process.stderr.write("\n");
@@ -149,6 +149,7 @@ interface QueuedSpawn {
149
149
  jobId: string;
150
150
  entry: string;
151
151
  bin: string;
152
+ onComplete?: (jobId: string, code: number) => void;
152
153
  }
153
154
 
154
155
  let gpuLeaseHolder: string | null = null;
@@ -170,6 +171,9 @@ export interface SubprocessOpts {
170
171
  entry?: string;
171
172
  /** Override the runtime binary (tests). Defaults to "bun". */
172
173
  bin?: string;
174
+ /** Called on the server (parent) after the child exits — used to invalidate
175
+ * caches (e.g. the Library) so a finished quantize surfaces immediately. */
176
+ onComplete?: (jobId: string, code: number) => void;
173
177
  }
174
178
 
175
179
  /** Create a 'queued' row and either spawn it now (lease free) or leave it
@@ -188,6 +192,7 @@ export function submitSubprocess(
188
192
  jobId: row.id,
189
193
  entry: opts.entry ?? JOB_ENTRY_PATH,
190
194
  bin: opts.bin ?? "bun",
195
+ onComplete: opts.onComplete,
191
196
  };
192
197
  if (gpuLeaseHolder === null) {
193
198
  spawnNow(item);
@@ -243,6 +248,7 @@ function spawnNow(item: QueuedSpawn): void {
243
248
  }
244
249
  }
245
250
  releaseLease(jobId);
251
+ try { item.onComplete?.(jobId, code); } catch {}
246
252
  drainQueue();
247
253
  })();
248
254
  }
@@ -0,0 +1,25 @@
1
+ // mlx-bun memory — synthesis stage 2: CHUNK. ⚠️ M1 STUB.
2
+ //
3
+ // Segment each new conversation into topic chunks (a local-model call — this is
4
+ // the segmentation track distilled from gold; eval by boundary/label accuracy).
5
+ // Re-chunk only conversations whose updated_at > chunked_at. Mirrors lucien's
6
+ // chunk-recent.ts. The prompt is the future "chunk" LoRA seam (prompts.ts).
7
+ //
8
+ // See docs/design/memory-system.md → "The nightly pipeline".
9
+
10
+ import type { SynthesisEvent } from "./pipeline";
11
+
12
+ export interface ChunkResult {
13
+ conversations: number;
14
+ chunks: number;
15
+ }
16
+
17
+ /** Conversations → topic chunks (local model). STUB. */
18
+ export async function chunkConversations(
19
+ _model: unknown,
20
+ _onEvent?: (e: SynthesisEvent) => void,
21
+ ): Promise<ChunkResult> {
22
+ // TODO(M1): for each conv with updated_at > chunked_at, run the chunk prompt
23
+ // on the local model, write chunks, set chunked_at.
24
+ throw new Error("memory.chunk is not implemented yet (M1)");
25
+ }
@@ -0,0 +1,24 @@
1
+ // mlx-bun memory — synthesis stage 3: CLUSTER. ⚠️ M1 STUB.
2
+ //
3
+ // Assign each new chunk to an existing bucket or propose a new one (local-model
4
+ // call). Buckets are emergent (Meta/Buckets.md), each mapping to a target
5
+ // article. Mirrors lucien's cluster-assign-recent.ts.
6
+ //
7
+ // See docs/design/memory-system.md → "The nightly pipeline".
8
+
9
+ import type { SynthesisEvent } from "./pipeline";
10
+
11
+ export interface ClusterResult {
12
+ assigned: number;
13
+ newBuckets: number;
14
+ }
15
+
16
+ /** Chunks → buckets (local model). STUB. */
17
+ export async function clusterChunks(
18
+ _model: unknown,
19
+ _onEvent?: (e: SynthesisEvent) => void,
20
+ ): Promise<ClusterResult> {
21
+ // TODO(M1): for each unbucketed chunk, classify into Meta/Buckets.md taxonomy
22
+ // (or propose a new bucket → new article stem); persist chunk.bucket.
23
+ throw new Error("memory.cluster is not implemented yet (M1)");
24
+ }
@@ -0,0 +1,114 @@
1
+ // mlx-bun memory — synthesis bookkeeping store (bun:sqlite).
2
+ //
3
+ // ⚠️ M1 STUB schema-wise the tables are real, but no stage writes them yet.
4
+ // The read path (vault.ts / tools.ts) does NOT touch this — it's only used by
5
+ // the synthesis pipeline (pipeline.ts), which is itself stubbed.
6
+ //
7
+ // Reuses mlx-bun's existing per-domain sqlite pattern (cf. JobStore in
8
+ // src/jobs/db.ts, EvalDB in src/evaldb.ts): a small class over bun:sqlite with
9
+ // WAL + schema-in-constructor + an additive migrate() hook, living under
10
+ // ~/.cache/mlx-bun/ alongside jobs.sqlite / evals.sqlite / registry.sqlite. It's
11
+ // derived state — rebuildable from the pi transcripts + the vault articles — so
12
+ // it belongs in the rebuildable cache, NOT in the (precious, git-tracked) vault.
13
+ //
14
+ // The schema mirrors lucien's incremental design: per-source watermarks (don't
15
+ // re-ingest), chunked_at > updated_at (re-chunk only changed convs), and a
16
+ // synthesized-chunk ledger (never fold the same chunk into an article twice).
17
+
18
+ import { Database } from "bun:sqlite";
19
+ import { mkdirSync } from "node:fs";
20
+
21
+ export const DEFAULT_MEMORY_DB = `${process.env.HOME}/.cache/mlx-bun/memory.sqlite`;
22
+
23
+ export interface ConversationRow {
24
+ /** Stable conv id: first 8 hex of the session UUID (the conv:HASH citation). */
25
+ conv: string;
26
+ source: string; // "pi-web" | "pi-terminal"
27
+ title: string;
28
+ /** Full normalized transcript JSON ({role, content}[]). */
29
+ transcript: string;
30
+ updated_at: number;
31
+ chunked_at: number | null;
32
+ }
33
+
34
+ export interface ChunkRow {
35
+ id: string;
36
+ conv: string;
37
+ ordinal: number;
38
+ text: string;
39
+ bucket: string | null;
40
+ }
41
+
42
+ export interface BucketRow {
43
+ name: string;
44
+ article: string; // target article stem
45
+ description: string;
46
+ }
47
+
48
+ const SCHEMA = `
49
+ CREATE TABLE IF NOT EXISTS conversations (
50
+ conv TEXT PRIMARY KEY,
51
+ source TEXT NOT NULL,
52
+ title TEXT NOT NULL DEFAULT '',
53
+ transcript TEXT NOT NULL,
54
+ updated_at INTEGER NOT NULL,
55
+ chunked_at INTEGER
56
+ );
57
+ CREATE TABLE IF NOT EXISTS chunks (
58
+ id TEXT PRIMARY KEY,
59
+ conv TEXT NOT NULL REFERENCES conversations(conv),
60
+ ordinal INTEGER NOT NULL,
61
+ text TEXT NOT NULL,
62
+ bucket TEXT
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_chunks_conv ON chunks(conv);
65
+ CREATE TABLE IF NOT EXISTS buckets (
66
+ name TEXT PRIMARY KEY,
67
+ article TEXT NOT NULL,
68
+ description TEXT NOT NULL DEFAULT ''
69
+ );
70
+ -- Per-source ingest watermark: only process what's new since last run.
71
+ CREATE TABLE IF NOT EXISTS watermarks (
72
+ source TEXT PRIMARY KEY,
73
+ cursor TEXT NOT NULL,
74
+ updated_at INTEGER NOT NULL
75
+ );
76
+ -- Idempotency ledger: a (bucket, chunk) pair is folded into an article once.
77
+ CREATE TABLE IF NOT EXISTS synthesized_bucket_chunks (
78
+ bucket TEXT NOT NULL,
79
+ chunk_id TEXT NOT NULL,
80
+ PRIMARY KEY (bucket, chunk_id)
81
+ );
82
+ `;
83
+
84
+ /**
85
+ * Synthesis bookkeeping DB. Same shape as JobStore/EvalDB: open in the
86
+ * constructor (WAL on, schema ensured, additive migrate), expose `db`, and a
87
+ * `close()`. Only the synthesis pipeline constructs one — opening it is what
88
+ * *creates* memory.sqlite, so the read path never does. Stage queries land in
89
+ * M1; the tables they expect already exist here.
90
+ */
91
+ export class MemoryStore {
92
+ readonly db: Database;
93
+ readonly dbPath: string;
94
+
95
+ constructor(dbPath: string = DEFAULT_MEMORY_DB) {
96
+ if (dbPath !== ":memory:") {
97
+ try { mkdirSync(dbPath.slice(0, dbPath.lastIndexOf("/")), { recursive: true }); } catch {}
98
+ }
99
+ this.dbPath = dbPath;
100
+ this.db = new Database(dbPath);
101
+ this.db.exec("PRAGMA journal_mode = WAL");
102
+ this.db.exec(SCHEMA);
103
+ this.migrate();
104
+ }
105
+
106
+ /** Additive-only migrations (mirrors JobStore.migrate). Re-opening is a no-op. */
107
+ private migrate(): void {
108
+ // No columns to add today; the hook stays so future fields are additive.
109
+ }
110
+
111
+ close(): void {
112
+ this.db.close();
113
+ }
114
+ }
@@ -0,0 +1,22 @@
1
+ // mlx-bun memory — synthesis stage 1: INGEST. ⚠️ M1 STUB.
2
+ //
3
+ // pi sessions (~/.mlx-bun/sessions + ~/.mlx-bun/pi, pi's own JSONL) →
4
+ // memory.sqlite, normalized to {conv, source, title, transcript}, watermark-
5
+ // gated so each run only processes what's new. No web scraping, no Playwright:
6
+ // mlx-bun synthesizes ITS OWN sessions only. Mirrors lucien's ingest-recent.ts.
7
+ //
8
+ // See docs/design/memory-system.md → "Ingestion — mlx-bun sessions only".
9
+
10
+ import type { SynthesisEvent } from "./pipeline";
11
+
12
+ export interface IngestResult {
13
+ scanned: number;
14
+ ingested: number;
15
+ }
16
+
17
+ /** Read new pi sessions into memory.sqlite. STUB: returns zero, does nothing. */
18
+ export async function ingestSessions(_onEvent?: (e: SynthesisEvent) => void): Promise<IngestResult> {
19
+ // TODO(M1): SessionManager.listAll() → normalize → upsert into conversations,
20
+ // advancing the per-source watermark. pi's session format is the contract.
21
+ throw new Error("memory.ingest is not implemented yet (M1)");
22
+ }
@@ -0,0 +1,63 @@
1
+ // mlx-bun memory — synthesis pipeline orchestrator. ⚠️ M1 STUB.
2
+ //
3
+ // Ties the stages together: ingest → chunk → cluster → synthesize → wikify →
4
+ // changelog (the lucien nightly chain, reimplemented on the local model). What
5
+ // `mlx-bun memory synthesize` and the nightly launchd job invoke.
6
+ //
7
+ // CURRENTLY A NO-OP: it logs each stage as stubbed and writes nothing, so the
8
+ // scheduled job runs cleanly tonight and every night without touching your
9
+ // vault. When M1 lands, flip runSynthesis to actually call the stage modules
10
+ // (each currently throws "not implemented") and move it onto the src/jobs/*
11
+ // background runner with SSE progress, per the design doc.
12
+ //
13
+ // See docs/design/memory-system.md → "The nightly pipeline".
14
+
15
+ export type SynthesisStage = "ingest" | "chunk" | "cluster" | "synthesize" | "wikify" | "changelog";
16
+
17
+ export interface SynthesisEvent {
18
+ type: "stage" | "log" | "done";
19
+ stage?: SynthesisStage;
20
+ message: string;
21
+ }
22
+
23
+ export interface SynthesisOptions {
24
+ /** Only synthesize conversations newer than this (ISO date / conv cursor). */
25
+ since?: string;
26
+ /** Override the synthesis model (default e4b). */
27
+ model?: string;
28
+ /** Plan only — never write the vault. */
29
+ dryRun?: boolean;
30
+ }
31
+
32
+ export interface SynthesisSummary {
33
+ /** False while stubbed; flip to true when the stages are implemented. */
34
+ implemented: boolean;
35
+ stages: SynthesisStage[];
36
+ note: string;
37
+ }
38
+
39
+ const STAGES: SynthesisStage[] = ["ingest", "chunk", "cluster", "synthesize", "wikify", "changelog"];
40
+
41
+ /**
42
+ * Run the synthesis pipeline. STUB: walks the stage list, emits a `stage` event
43
+ * for each (marked skipped), and returns implemented:false. Deterministic and
44
+ * side-effect-free, so it's safe for the nightly job to call today.
45
+ */
46
+ export async function runSynthesis(
47
+ opts: SynthesisOptions = {},
48
+ onEvent?: (e: SynthesisEvent) => void,
49
+ ): Promise<SynthesisSummary> {
50
+ onEvent?.({
51
+ type: "log",
52
+ message: `memory synthesis is not implemented yet (M1)${opts.dryRun ? " · dry-run" : ""} — running as a no-op, nothing will be written.`,
53
+ });
54
+ for (const stage of STAGES) {
55
+ onEvent?.({ type: "stage", stage, message: `${stage}: stubbed (skipped)` });
56
+ }
57
+ onEvent?.({ type: "done", message: "no articles were written (synthesis is stubbed)." });
58
+ return {
59
+ implemented: false,
60
+ stages: STAGES,
61
+ note: "M1 stub — pipeline + scheduling wired; stage modules not implemented.",
62
+ };
63
+ }
@@ -0,0 +1,23 @@
1
+ // mlx-bun memory — per-stage synthesis prompts. ⚠️ M1 STUBS.
2
+ //
3
+ // Each stage's prompt is the seam where a future specialized memory LoRA plugs
4
+ // in (today: base model + prompt; later: adapterScoped per stage). Editorial
5
+ // conventions are NOT duplicated here — they live in the vault's Meta/ pages
6
+ // (Editorial_Guidelines.md, Article_Conventions.md, Buckets.md,
7
+ // Topics_to_Ignore.md) and are read in at synthesis time so the system governs
8
+ // itself in its own substrate.
9
+ //
10
+ // See docs/design/memory-system.md → "Synthesis runs on the local model".
11
+
12
+ export const CHUNK_PROMPT = `TODO(M1): segment a conversation transcript into topic-coherent chunks.`;
13
+
14
+ export const CLUSTER_PROMPT = `TODO(M1): assign a chunk to an existing bucket from Meta/Buckets.md, or propose a new bucket.`;
15
+
16
+ export const SYNTHESIZE_PROMPT = `TODO(M1): integrate new chunks into the bucket's article. Preserve prose and every conv: citation; integrate, don't overwrite; respect the ≥70% word-count floor and the editorial gate.`;
17
+
18
+ /** Placeholder registry so the pipeline can reference prompts by stage name. */
19
+ export const STAGE_PROMPTS: Record<string, string> = {
20
+ chunk: CHUNK_PROMPT,
21
+ cluster: CLUSTER_PROMPT,
22
+ synthesize: SYNTHESIZE_PROMPT,
23
+ };