dsh-hypatia-auto-memory 0.1.1 → 0.3.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/lib/client.js CHANGED
@@ -108,46 +108,46 @@ window.__ModuleLoader__.load({
108
108
  document.head.appendChild(tag);
109
109
  }
110
110
  var SettingsCard_module_css_default = {
111
- "headText": "YVNYya_headText",
112
- "chevronOpen": "YVNYya_chevronOpen",
113
- "chevron": "YVNYya_chevron",
111
+ "limitsTitle": "YVNYya_limitsTitle",
112
+ "modelGroup": "YVNYya_modelGroup",
114
113
  "model": "YVNYya_model",
114
+ "headText": "YVNYya_headText",
115
+ "card": "YVNYya_card",
116
+ "readOnly": "YVNYya_readOnly",
117
+ "cardOpen": "YVNYya_cardOpen",
118
+ "hint": "YVNYya_hint",
119
+ "group": "YVNYya_group",
120
+ "label": "YVNYya_label",
121
+ "providerName": "YVNYya_providerName",
115
122
  "catalogError": "YVNYya_catalogError",
116
- "groupTitle": "YVNYya_groupTitle",
123
+ "chevronOpen": "YVNYya_chevronOpen",
124
+ "pending": "YVNYya_pending",
125
+ "unavailable": "YVNYya_unavailable",
117
126
  "input": "YVNYya_input",
118
- "header": "YVNYya_header",
119
- "group": "YVNYya_group",
120
- "advancedHint": "YVNYya_advancedHint",
121
- "hint": "YVNYya_hint",
122
- "modelSelection": "YVNYya_modelSelection",
123
- "switch": "YVNYya_switch",
124
- "readOnly": "YVNYya_readOnly",
125
- "fieldHead": "YVNYya_fieldHead",
126
- "switchOn": "YVNYya_switchOn",
127
127
  "reset": "YVNYya_reset",
128
+ "description": "YVNYya_description",
128
129
  "select": "YVNYya_select",
129
- "card": "YVNYya_card",
130
- "body": "YVNYya_body",
131
- "cardOpen": "YVNYya_cardOpen",
132
- "modelName": "YVNYya_modelName",
133
- "limitsTitle": "YVNYya_limitsTitle",
134
- "label": "YVNYya_label",
135
- "pairedFields": "YVNYya_pairedFields",
136
- "switchThumb": "YVNYya_switchThumb",
137
- "status": "YVNYya_status",
138
- "notice": "YVNYya_notice",
139
130
  "field": "YVNYya_field",
140
- "models": "YVNYya_models",
141
- "providerName": "YVNYya_providerName",
131
+ "switchOn": "YVNYya_switchOn",
132
+ "advancedHint": "YVNYya_advancedHint",
142
133
  "name": "YVNYya_name",
143
- "description": "YVNYya_description",
144
- "error": "YVNYya_error",
145
- "warning": "YVNYya_warning",
134
+ "status": "YVNYya_status",
135
+ "switchThumb": "YVNYya_switchThumb",
146
136
  "route": "YVNYya_route",
147
- "unavailable": "YVNYya_unavailable",
137
+ "models": "YVNYya_models",
138
+ "groupTitle": "YVNYya_groupTitle",
139
+ "error": "YVNYya_error",
140
+ "modelSelection": "YVNYya_modelSelection",
148
141
  "footer": "YVNYya_footer",
149
- "pending": "YVNYya_pending",
150
- "modelGroup": "YVNYya_modelGroup"
142
+ "pairedFields": "YVNYya_pairedFields",
143
+ "body": "YVNYya_body",
144
+ "modelName": "YVNYya_modelName",
145
+ "switch": "YVNYya_switch",
146
+ "chevron": "YVNYya_chevron",
147
+ "notice": "YVNYya_notice",
148
+ "header": "YVNYya_header",
149
+ "fieldHead": "YVNYya_fieldHead",
150
+ "warning": "YVNYya_warning"
151
151
  };
152
152
  //#endregion
153
153
  //#region src/client/locales.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hypatia-auto-memory",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -45,13 +45,13 @@
45
45
  "node": "^22.19.0 || >=24.0.0"
46
46
  },
47
47
  "dependencies": {
48
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
49
- "@deepseek-ai/dsh-storage-domain": "^0.1.2-rc.1",
50
48
  "@deepseek-ai/schemastery": "^3.18.2",
51
49
  "zod": "^4.4.3"
52
50
  },
53
51
  "peerDependencies": {
54
52
  "@deepseek-ai/cordis": "^4.0.2",
53
+ "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
54
+ "@deepseek-ai/dsh-storage-domain": "^0.1.2-rc.1",
55
55
  "react": "^18.2.0"
56
56
  },
57
57
  "devDependencies": {
@@ -64,6 +64,8 @@
64
64
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
65
65
  "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-rc.1",
66
66
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
67
+ "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
68
+ "@deepseek-ai/dsh-storage-domain": "^0.1.2-rc.1",
67
69
  "@types/react": "^18.2.0",
68
70
  "lightningcss": "^1.32.0",
69
71
  "react": "^18.2.0",
@@ -68,6 +68,38 @@ hypatia knowledge-create "prefer immutable" -d "always create new objects" --tag
68
68
  hypatia knowledge-create "no mock DB" -d "never mock database in tests" --tags "taboo" --scopes "my-project,"
69
69
  ```
70
70
 
71
+ ### Listing the scopes and tags already in use
72
+
73
+ A scope or tag you invent is not an error — the entry is stored, and every later
74
+ lookup by the value you meant misses it. Check before you write:
75
+
76
+ ```bash
77
+ # What this shelf already uses, with how many entries carry each
78
+ hypatia scope list --count
79
+ hypatia tag list --count
80
+
81
+ # Exact values, when you are going to write one back
82
+ hypatia scope list --json # [{"value":"","entries":12},{"value":"my-project",…}]
83
+
84
+ # Confirm one spelling; exit 0 if in use, 1 if not
85
+ hypatia tag exists rule
86
+ hypatia scope exists my-project
87
+ hypatia scope exists "" # the global scope
88
+ ```
89
+
90
+ The plain listing prints the global scope as `(global)`. That is a label for the
91
+ terminal, not the value — the value is the empty string, and `--json` and
92
+ `exists` both give it back verbatim. Never copy `(global)` into `--scopes`.
93
+
94
+ Both cover knowledge and statements. An entry that declares no scopes at all is
95
+ not listed as global.
96
+
97
+ | User says | Command |
98
+ |---|---|
99
+ | "what scopes/projects are in here?" | `hypatia scope list --count` |
100
+ | "what tags exist?" / "which labels are used?" | `hypatia tag list --count` |
101
+ | "is there already a `rule` tag?" | `hypatia tag exists rule` |
102
+
71
103
  ## Knowledge CRUD
72
104
 
73
105
  Knowledge entries are independent information points with a name, content, and tags.
@@ -75,14 +107,17 @@ Knowledge entries are independent information points with a name, content, and t
75
107
  ### Create
76
108
 
77
109
  ```
78
- hypatia knowledge-create <name> -d "<data>" -t "<tag1,tag2>" --figures "archive://path/to/file"
110
+ hypatia knowledge-create <name> -d "<data>" -t "<tag1,tag2>" --figures "archive://path/to/file" [--no-embed]
79
111
  ```
80
112
 
113
+ `--no-embed` keeps the entry out of the vector index: it is still stored and still found by `search` and `query`, but it is never embedded, so it costs no model call on the way in and is not a `similar` result. Use it for raw session logs and other bulk that carries no distilled knowledge. A shelf can skip whole tags instead, with `embedding.skip_tags` in `shelf.toml`.
114
+
81
115
  | User says | Command |
82
116
  |---|---|
83
117
  | "remember Rust as a systems programming language" | `hypatia knowledge-create "Rust" -d "systems programming language" -t "language,compiled"` |
84
118
  | "save knowledge about Go with tags language and compiled" | `hypatia knowledge-create "Go" -d "" -t "language,compiled"` |
85
119
  | "store that Python is a scripting language, tag it as dynamic" | `hypatia knowledge-create "Python" -d "scripting language" -t "dynamic"` |
120
+ | "log this turn but don't make it searchable by meaning" | `hypatia knowledge-create "msg-42" -d "<turn>" -t "message" --no-embed` |
86
121
 
87
122
  ### Read
88
123
 
@@ -97,9 +132,11 @@ hypatia knowledge-get <name>
97
132
  ### Update
98
133
 
99
134
  ```
100
- hypatia knowledge-update <name> [-d "<data>"] [-t "<tags>"] [--synonyms "<csv>"] [--figures "<refs>"] [--scopes "<scopes>"]
135
+ hypatia knowledge-update <name> [-d "<data>"] [-t "<tags>"] [--synonyms "<csv>"] [--figures "<refs>"] [--scopes "<scopes>"] [--no-embed | --embed]
101
136
  ```
102
137
 
138
+ `--no-embed` takes the entry out of the vector index and discards the vector it had; `--embed` puts it back, and it is embedded on the next flush. The two are mutually exclusive. `--embed` does not override the shelf's `embedding.skip_tags`: an entry can ask for less indexing than its shelf, never more.
139
+
103
140
  Only the fields you pass change. An omitted field keeps its stored value, and exactly `""` clears a field, such as `-t ""`; for tags, `","` or `" "` would store blank tags instead. `--scopes` replaces the stored scopes and is parsed as on create: end the list with a comma, such as `"q,"`, to include the global scope, or the entry drops out of global lookups. The entry keeps its `created_at`. Its old vector is discarded, and a new one is generated on the next flush, as after `knowledge-create`. Updating an entry that does not exist is an error. An update that changes nothing prints `Knowledge unchanged: <name>` and writes nothing.
104
141
 
105
142
  | User says | Command |
@@ -129,9 +166,11 @@ When the user asks to store or remember information, **always create statements
129
166
  **Pattern**: After creating a knowledge entry, identify entities mentioned in the content and create `$triple` relationships between them. At minimum, create one `is_a` statement for every new knowledge entry.
130
167
 
131
168
  ```
132
- hypatia statement-create <head> <relation> <tail> -d "<data>"
169
+ hypatia statement-create <head> <relation> <tail> -d "<data>" [--no-embed]
133
170
  ```
134
171
 
172
+ `--no-embed` works as it does on `knowledge-create`: the triple is still stored and still traversable, but gets no vector. Two differences: statements carry no tags, so a shelf's `embedding.skip_tags` cannot reach them and bulk links must say `--no-embed` themselves; and there is no `statement-update`, so the choice is made once at creation.
173
+
135
174
  ### Triple Extraction Patterns
136
175
 
137
176
  | User says | head | relation | tail |
@@ -341,19 +380,25 @@ hypatia search <query> [-c <catalog>] [--limit N] [--offset N]
341
380
  Semantic search using embedding vectors. Finds entries with similar meaning, even when keywords don't match.
342
381
 
343
382
  ```
344
- hypatia similar <query> [--limit N]
383
+ hypatia similar <query> [--limit N] [-t knowledge|statement|both] [--tags <a,b>] [--exclude-tags <a,b>] [--where '<JSE condition>']
345
384
  ```
346
385
 
386
+ `--tags` keeps entries carrying at least one of the tags, `--exclude-tags` drops entries carrying any of them, and `--where` takes a JSE condition such as `["$contains", "scopes", "project-a"]` (`$eq`, `$like`, `$contains`, `$and`/`$or`/`$not` and the other filters `$knowledge` takes, but not `$search`, `$similar` or `$k-hop`). They narrow the entries before ranking, so `--limit N` still returns N entries whenever that many qualify. `--exclude-tags message,summary,session` keeps the session-log layer from crowding out the knowledge distilled from it. With the default target `both` the filter applies to statements too, so a condition on `name` needs `-t knowledge`.
387
+
347
388
  Requires an embedding model configured in `shelf.toml` (default: BAAI/bge-m3).
348
389
 
349
390
  Entries are embedded in batches after they are written or updated. With a remote embedding API, `similar` can miss entries changed in the last minute; `search` finds them at once.
350
391
 
392
+ Entries written with `--no-embed`, and entries carrying a tag the shelf lists in `embedding.skip_tags`, are never embedded and so are never `similar` results. `search` and `query` still find them. Each entry keeps the answer it was written with, so after changing `skip_tags` run `hypatia backfill` once to settle what is already stored.
393
+
351
394
  ### Examples
352
395
 
353
396
  | User says | Command |
354
397
  |---|---|
355
398
  | "find similar to distributed systems" | `hypatia similar "distributed systems"` |
356
399
  | "semantic search for memory management" | `hypatia similar "memory management" --limit 5` |
400
+ | "what do we know about auth, not the chat logs" | `hypatia similar "auth" --exclude-tags message,summary,session` |
401
+ | "rules like this one for project-a" | `hypatia similar "<rule text>" -t knowledge --tags rule --where '["$contains", "scopes", "project-a"]'` |
357
402
 
358
403
  ## JSE Query Translation
359
404
 
@@ -559,15 +604,17 @@ even read-only SQLite needs to create WAL/journal temp files next to the databas
559
604
  go straight to the immutable read-only URI, which needs no journal and no directory writes:
560
605
 
561
606
  ```bash
562
- sqlite3 "file:$HOME/.hypatia/default/hypatia.sqlite?mode=ro&immutable=1" "SELECT count(*) FROM knowledge;"
607
+ sqlite3 "file:<shelf-dir>/hypatia.sqlite?mode=ro&immutable=1" "SELECT count(*) FROM knowledge;"
563
608
  ```
564
609
 
565
610
  - Use only for **reads** (SELECT). Never write through this path — `immutable=1` tells SQLite the
566
611
  file never changes, so writes would corrupt state or be lost.
567
- - Adjust the shelf path for non-default shelves (`~/.hypatia/<shelf>/<db>.sqlite`).
568
- - First, locate the actual DB file: `ls ~/.hypatia/*/` (also check `-wal`/`-shm` siblings; if a
569
- WAL exists and is non-empty, `immutable=1` may miss recent un-checkpointed rows — in that case
570
- prefer requesting wider sandbox permissions for a normal read instead).
612
+ - First, get `<shelf-dir>` from `hypatia list`, which prints the directory each shelf is open at.
613
+ Do not assume `~/.hypatia/<shelf>/`: any shelf, `default` included, can be registered elsewhere.
614
+ If the CLI itself cannot run, read the paths from `~/.hypatia/shelves.json`.
615
+ - Then check the `-wal`/`-shm` siblings; if a WAL exists and is non-empty, `immutable=1` may miss
616
+ recent un-checkpointed rows — in that case prefer requesting wider sandbox permissions for a
617
+ normal read instead.
571
618
  - Schema hints: tables include `knowledge` and `statement`; statement triples are stored in
572
619
  `head` / `relation` / `tail` columns.
573
620
 
@@ -25,6 +25,11 @@ Do not do these by hand — you would only create duplicates:
25
25
  - **Session seed.** Project and global entries tagged `rule` or `taboo` are
26
26
  injected into your context at session start. Nothing else is pushed to you.
27
27
 
28
+ The log layer — `msg-*`, `session-*`, and the `belongTo` / `summary` edges —
29
+ is written **without vectors** (`--no-embed`) on a hypatia that has the flag.
30
+ It is stored and full-text searchable, but `similar` does not rank it. Entries
31
+ logged before that, or by an older hypatia, still carry vectors.
32
+
28
33
  ## Which shelf
29
34
 
30
35
  The plugin writes to one hypatia shelf, chosen in its settings. When that is
@@ -47,7 +52,8 @@ costs a wrong answer that contradicts a decision already made.
47
52
  ### Semantic search — the default
48
53
 
49
54
  ```bash
50
- hypatia similar "<what you actually want to know>" -t knowledge --limit 20
55
+ hypatia similar "<what you actually want to know>" -t knowledge --limit 5 \
56
+ --exclude-tags message,summary,session,hypatia-dream-run
51
57
  ```
52
58
 
53
59
  Write the query as the *idea* you are looking for, not the user's words verbatim.
@@ -55,8 +61,11 @@ Rows come back as `{"name", "content" (object), "distance"}`; lower `distance` i
55
61
  closer. If it exits non-zero with `model unavailable` / `no embedding provider`,
56
62
  this shelf has no embedding model — fall back to `search` and carry on.
57
63
 
58
- The limit is deliberately about four times the rows you want: most of what comes
59
- back will be the operational layer, which you then drop (see below).
64
+ `--exclude-tags` leaves the operational layer out *before* ranking, so five rows
65
+ asked for are five rows of knowledge. Add `--where '["$contains","scopes","<PROJECT>"]'`
66
+ to stay inside this project. If the flag is rejected as an `unexpected argument`,
67
+ the hypatia is older than #35: drop it, ask for `--limit 20`, and discard the
68
+ operational rows yourself (see below).
60
69
 
61
70
  ### Keyword search — for exact identifiers
62
71
 
@@ -79,10 +88,21 @@ hypatia query '["$knowledge", ["$contains","tags","rule"],
79
88
  hypatia query '["$statement", ["$k-hop", "<entry-name>", "$*", 2]]'
80
89
  ```
81
90
 
82
- `<PROJECT>` is the basename of the workspace's git root (of the workspace
83
- itself outside git), written as the plugin writes it: commas become `_`,
84
- surrounding whitespace is dropped, and the filesystem root is `/`. Written raw,
85
- a name with a comma would be split into two scopes.
91
+ `<PROJECT>` is the scope the plugin writes this workspace under: the basename
92
+ of its git root (of the workspace itself outside git), with commas turned into
93
+ `_`, surrounding whitespace dropped, and the filesystem root written `/`. You
94
+ do not have to derive it — the plugin has already logged this session under it,
95
+ so it is on the shelf:
96
+
97
+ ```bash
98
+ hypatia scope list --count # every scope in use, with how many entries carry it
99
+ hypatia scope exists "<PROJECT>" # exit 0 if in use, 1 if not
100
+ ```
101
+
102
+ The plain listing prints the global scope as `(global)`; that is a label, the
103
+ value is `""`. Use `--json` when you will write a value back. Both need hypatia
104
+ #30; an older one answers `unrecognized subcommand`, and then the rule above is
105
+ all there is.
86
106
 
87
107
  ### Always exclude the operational layer
88
108
 
@@ -92,13 +112,14 @@ name starts with `msg-`, `sum`, `session-`, or `hypatia-dream-run-`, and rows
92
112
  tagged `message`, `session`, `summary`, or `hypatia-dream-run`. Read `wu-*`,
93
113
  `rule`, `taboo`, and ordinary named entries.
94
114
 
95
- Neither `similar` nor `search` can filter by name or tag, and that layer is most
96
- of the shelf — every message is logged. It also matches well: a raw `msg-*`
97
- holds the very words of the conversation you are asking about. Measured on a
98
- live shelf, 7 of the 10 nearest `similar` rows were operational, so a
99
- `--limit 5` search filtered afterwards leaves one row or none. Hence `--limit 20`. If too few rows
100
- survive the filter, search again with a larger limit or a different wording
101
- before concluding that nothing is stored.
115
+ That layer is most of the shelf — every message is logged — and it matches
116
+ well: a raw `msg-*` holds the very words of the conversation you are asking
117
+ about. Measured on a live shelf, 7 of the 10 nearest `similar` rows were
118
+ operational. `similar --exclude-tags` handles it; `search` has no such flag, and
119
+ neither can see an operational entry that lost its tags, so still check names.
120
+ Whenever you filter after the fact, ask for `--limit 20`, and if too few rows
121
+ survive, search again with a larger limit or a different wording before
122
+ concluding that nothing is stored.
102
123
 
103
124
  Reach into `msg-*` only when the user asks what was literally said, and prefer
104
125
  reaching it by walking `summary` statements down from a `sum-*` entry.
@@ -119,8 +140,14 @@ hypatia knowledge-create "<short-kebab-name>" \
119
140
  --scopes "<PROJECT>"
120
141
  ```
121
142
 
143
+ - **Write the scope the plugin uses**, exactly. The session seed loads rules by
144
+ that spelling; a rule under `my-app` is invisible to a plugin that logs under
145
+ `my_app`. `hypatia scope list --count` shows it — the scope this project's
146
+ `msg-*` entries carry — and `hypatia scope exists "<PROJECT>"` confirms it
147
+ before you write.
122
148
  - `--tags rule` for conventions to follow, `taboo` for never-do items,
123
- `memory` for durable facts.
149
+ `memory` for durable facts. Beyond those three, reuse what `hypatia tag list`
150
+ already shows before inventing a label.
124
151
  - **Scope syntax matters.** `--scopes "<PROJECT>"` is project-only.
125
152
  `--scopes "<PROJECT>,"` — with a *trailing comma* — also marks it global.
126
153
  `--scopes ""` writes no scope at all, which is not the same as global and will
package/src/cascade.js CHANGED
@@ -179,8 +179,12 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
179
179
  // under a different name and the first entry keeps only its partial set.
180
180
  // Nothing is lost — every member still ends up archived exactly once — but
181
181
  // the grouping boundary is not the one the first attempt intended.
182
+ // The archive entry is embedded — it is distilled knowledge, and the entry
183
+ // point for drilling down. Its edges are not: they are for the traversal
184
+ // and for `$not-summaried`, and nobody looks for a `summary` edge by
185
+ // meaning (see writer.js `writeSummary`).
182
186
  for (const member of members) {
183
- await cli.statementCreate(name, 'summary', member, { scopes: [task.project] })
187
+ await cli.statementCreate(name, 'summary', member, { scopes: [task.project], embed: false })
184
188
  }
185
189
  status.info(`cascade: tier ${level} archived ${members.length} entries as ${name}`)
186
190
  }
package/src/collector.js CHANGED
@@ -59,6 +59,26 @@ const RECORDED_TYPES = new Set([
59
59
  'tool/result',
60
60
  ])
61
61
 
62
+ /**
63
+ * Whether an assistant/message event carries anything worth logging on its own.
64
+ *
65
+ * A turn that only issued tool calls (no text, no reasoning, no interruption
66
+ * marker) adds nothing to the conversation record — the tool outcomes already
67
+ * live in the session log if needed. Skipping these avoids msg-* entries whose
68
+ * entire body would be `(tool calls only)` or an empty placeholder.
69
+ *
70
+ * @param {any} event
71
+ * @returns {boolean}
72
+ */
73
+ function hasAssistantContent(event) {
74
+ const blocks = event.data?.message?.content ?? []
75
+ for (const block of blocks) {
76
+ if (block.type === 'text' && String(block.text ?? '').trim() !== '') return true
77
+ if (block.type === 'image') return true
78
+ }
79
+ return false
80
+ }
81
+
62
82
  /**
63
83
  * Whether this event becomes one `msg-*` entry.
64
84
  *
@@ -66,11 +86,17 @@ const RECORDED_TYPES = new Set([
66
86
  * output) are excluded: they are not things the human said, and re-logging them
67
87
  * would let memory amplify into itself.
68
88
  *
89
+ * Assistant turns whose only content is tool calls are also excluded: they have
90
+ * no substantive message to remember.
91
+ *
69
92
  * @param {any} event
70
93
  * @returns {boolean}
71
94
  */
72
95
  export function isLoggableMessage(event) {
73
- if (event?.type === 'assistant/message') return true
96
+ if (event?.type === 'assistant/message') {
97
+ const interrupted = event.data?.interrupted === true
98
+ return interrupted || hasAssistantContent(event)
99
+ }
74
100
  return event?.type === 'user/message' && event.data?.source?.kind === 'user'
75
101
  }
76
102
 
package/src/config.js CHANGED
@@ -35,7 +35,11 @@ export const DEFAULTS = {
35
35
  // User messages were never capped, so a pasted log went into hypatia whole.
36
36
  // Generous, because what the user typed is the part most worth keeping.
37
37
  maxUserChars: 32000,
38
- toolLedger: true,
38
+ // Tool-call ledgers (name / count / success / duration) are intentionally
39
+ // off by default: they still reveal which tools were used and when, and the
40
+ // user has asked to avoid writing any tool-call-derived data to hypatia.
41
+ // Set this to true to restore the previous behavior.
42
+ toolLedger: false,
39
43
  },
40
44
  consolidation: {
41
45
  enabled: true,
@@ -14,7 +14,7 @@
14
14
  * @module dsh-hypatia-auto-memory/consolidator
15
15
  */
16
16
 
17
- import { absolutizeDates, blocksToText, eventDate, flattenToolResult, redactSecrets } from './content-policy.js'
17
+ import { absolutizeDates, blocksToText, eventDate, redactSecrets } from './content-policy.js'
18
18
  import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } from './collector.js'
19
19
  import { messageName, summaryName } from './writer.js'
20
20
  import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
@@ -178,8 +178,11 @@ export function buildTranscript(events, config, now = new Date(), { before } = {
178
178
  const callId = event.data?.message?.source?.callId
179
179
  const name = toolNames.get(callId) ?? 'tool'
180
180
  const ok = event.data?.error === undefined
181
- const first = clean(flattenToolResult(event), event).split('\n')[0] ?? ''
182
- entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'} ${first}`.trimEnd() })
181
+ // Tool outputs are intentionally kept out of the consolidation transcript.
182
+ // The model only sees that a tool was called and whether it succeeded;
183
+ // the actual result text never reaches the remote model or the summaries
184
+ // and work units derived from it.
185
+ entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'}` })
183
186
  }
184
187
  }
185
188
 
@@ -277,7 +277,11 @@ export function blocksToText(blocks) {
277
277
  for (const block of blocks ?? []) {
278
278
  if (block.type === 'text') parts.push(block.text)
279
279
  else if (block.type === 'image') parts.push('[image]')
280
- else if (block.type === 'thinking') parts.push(`[thinking: ${String(block.text ?? '').slice(0, 200)}]`)
280
+ // Thinking / reasoning blocks are internal monologue, not something the
281
+ // assistant said to the user. Including them created `[reasoning]`
282
+ // placeholder entries with no substantive content; skip them so only the
283
+ // final text / image output reaches the log.
284
+ else if (block.type === 'thinking' || block.type === 'reasoning') continue
281
285
  else if (block.type === 'tool-result') parts.push(blocksToText(block.content ?? []))
282
286
  // A tool-call block is the model asking for a tool, not something it said.
283
287
  // Rendered as `[tool-call]` it filled the content of half the stored
@@ -172,6 +172,56 @@ export function createHypatiaCli(ctx, config) {
172
172
  }
173
173
  }
174
174
 
175
+ /**
176
+ * What this binary can do beyond the baseline every call relies on.
177
+ *
178
+ * Read off `--help`, the one place clap states a flag's existence: an older
179
+ * binary answers an unknown flag with a usage error (exit 2) *instead of*
180
+ * running the command, so a write that guessed at `--no-embed` would fail
181
+ * outright on a binary from before hypatia #26, and a `similar` guessing at
182
+ * `--exclude-tags` on one from before #35. Both write commands are asked
183
+ * about `--no-embed` — they gained it together, but the answer is only worth
184
+ * having if it is true of each command it gates.
185
+ *
186
+ * @returns {Promise<{noEmbed: boolean, similarFilters: boolean}>}
187
+ */
188
+ async function probeFeatures() {
189
+ const [knowledge, statement, similar] = await Promise.all([
190
+ run(['knowledge-create', '--help']),
191
+ run(['statement-create', '--help']),
192
+ run(['similar', '--help']),
193
+ ])
194
+ for (const result of [knowledge, statement, similar]) {
195
+ if (result.exitCode !== 0) {
196
+ throw new HypatiaCliError(`hypatia --help exited ${result.exitCode}`, {
197
+ code: 'NONZERO_EXIT',
198
+ stderr: result.stderr.trim().slice(0, 500),
199
+ exitCode: result.exitCode,
200
+ })
201
+ }
202
+ }
203
+ return {
204
+ noEmbed: knowledge.stdout.includes('--no-embed') && statement.stdout.includes('--no-embed'),
205
+ similarFilters: similar.stdout.includes('--exclude-tags'),
206
+ }
207
+ }
208
+
209
+ /** The probe's answer, kept for the life of the client; a probe that failed is asked again. */
210
+ let featuresProbe
211
+
212
+ function features() {
213
+ if (featuresProbe === undefined) {
214
+ featuresProbe = probeFeatures().catch(() => {
215
+ // A binary that cannot even print its help fails the next real call
216
+ // with its own error; the answer here only decides which flags that
217
+ // call carries, and the conservative answer is none.
218
+ featuresProbe = undefined
219
+ return { noEmbed: false, similarFilters: false }
220
+ })
221
+ }
222
+ return featuresProbe
223
+ }
224
+
175
225
  /**
176
226
  * Whether stdout is hypatia's empty-result sentinel.
177
227
  *
@@ -196,6 +246,7 @@ export function createHypatiaCli(ctx, config) {
196
246
  return {
197
247
  run,
198
248
  runOk,
249
+ features,
199
250
 
200
251
  /**
201
252
  * Read one knowledge entry. Missing entries are a normal result, not an
@@ -211,7 +262,11 @@ export function createHypatiaCli(ctx, config) {
211
262
  },
212
263
 
213
264
  /**
214
- * @param {{data: string, tags?: string[], scopes?: string[], shelf?: string}} entry
265
+ * `embed: false` keeps the entry out of the vector index (hypatia #26); on
266
+ * a binary without the flag it is dropped, and the entry is embedded as it
267
+ * always was.
268
+ *
269
+ * @param {{data: string, tags?: string[], scopes?: string[], embed?: boolean, shelf?: string}} entry
215
270
  * @returns {Promise<boolean>} true when this call created the entry.
216
271
  */
217
272
  async knowledgeCreate(name, entry) {
@@ -222,11 +277,15 @@ export function createHypatiaCli(ctx, config) {
222
277
  if (entry.data) argv.push(`--data=${fitDataArgument(entry.data)}`)
223
278
  if (entry.tags && entry.tags.length > 0) argv.push('--tags', entry.tags.join(','))
224
279
  if (entry.scopes && entry.scopes.length > 0) argv.push('--scopes', entry.scopes.join(','))
280
+ if (entry.embed === false && (await features()).noEmbed) argv.push('--no-embed')
225
281
  return runCreate(argv)
226
282
  },
227
283
 
228
284
  /**
229
- * @param {{data?: string, scopes?: string[], shelf?: string}} [entry]
285
+ * `embed: false` as on `knowledgeCreate`. A statement has no update
286
+ * command, so the choice is made once, here.
287
+ *
288
+ * @param {{data?: string, scopes?: string[], embed?: boolean, shelf?: string}} [entry]
230
289
  * @returns {Promise<boolean>} true when this call created the triple.
231
290
  */
232
291
  async statementCreate(head, relation, tail, entry = {}) {
@@ -239,6 +298,7 @@ export function createHypatiaCli(ctx, config) {
239
298
  // expose to clap's flag parsing.
240
299
  if (entry.data) argv.push(`--data=${fitDataArgument(entry.data)}`)
241
300
  if (entry.scopes && entry.scopes.length > 0) argv.push('--scopes', entry.scopes.join(','))
301
+ if (entry.embed === false && (await features()).noEmbed) argv.push('--no-embed')
242
302
  return runCreate(argv)
243
303
  },
244
304
 
@@ -258,10 +318,18 @@ export function createHypatiaCli(ctx, config) {
258
318
  /**
259
319
  * Vector-similarity search — the recall path's primary ranking tool.
260
320
  * NOTE the flag is `-t/--target` (unlike keyword search's `-c`).
321
+ *
322
+ * `excludeTags` leaves entries carrying any of those tags out BEFORE the
323
+ * ranking (hypatia #35), so `limit` rows come back whenever that many
324
+ * qualify. A binary without the flag ranks everything; the caller reads
325
+ * `features()` to know whether it must over-fetch and filter instead.
326
+ *
261
327
  * @returns {Promise<any[]>}
262
328
  */
263
- async similar(query, { target = 'knowledge', limit = 5, shelf = shelfOf() } = {}) {
264
- const { stdout } = await runOk(['similar', query, '-t', target, '--limit', String(limit), '--shelf', shelf])
329
+ async similar(query, { target = 'knowledge', limit = 5, shelf = shelfOf(), excludeTags = [] } = {}) {
330
+ const argv = ['similar', query, '-t', target, '--limit', String(limit), '--shelf', shelf]
331
+ if (excludeTags.length > 0 && (await features()).similarFilters) argv.push('--exclude-tags', excludeTags.join(','))
332
+ const { stdout } = await runOk(argv)
265
333
  if (isEmptyResult(stdout)) return []
266
334
  const parsed = parseJson(stdout, 'similar')
267
335
  return Array.isArray(parsed) ? parsed : []
@@ -54,7 +54,21 @@ export function createHypatiaClient(ctx, config, { status, connection }) {
54
54
  }
55
55
  }
56
56
 
57
+ /** Logged once, so the profile log says which flags this run's writes carry. */
58
+ let announced = false
59
+ async function features() {
60
+ const found = await via('features', [])
61
+ if (!announced) {
62
+ announced = true
63
+ const yes = (flag) => (flag ? 'yes' : 'no')
64
+ status.info?.(`hypatia over ${usesMcp() ? 'mcp' : 'cli'}: no-embed ${yes(found.noEmbed)}, similar filters ${yes(found.similarFilters)}`)
65
+ }
66
+ return found
67
+ }
68
+
57
69
  return {
70
+ /** What the binary can do beyond the baseline (see hypatia-cli.js `features`). */
71
+ features,
58
72
  knowledgeGet: (...args) => via('knowledgeGet', args),
59
73
  knowledgeCreate: (...args) => via('knowledgeCreate', args),
60
74
  statementCreate: (...args) => via('statementCreate', args),