dsh-context-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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - **Remember** — append-only `.memory/events.jsonl`; every memory carries provenance
8
+ (session, step, author) so the question "why does the agent think it knows this" has
9
+ an answer.
10
+ - **Recall** — relevance-ranked retrieval (token overlap, recency, verification
11
+ strength) instead of dumping every stored memory into context.
12
+ - **Forget with a reason** — a memory is superseded, never silently deleted, and the
13
+ supersede event keeps the reason on the record.
14
+ - **Conflict detection** — two active memories about the same subject are reported as a
15
+ conflict; the plugin does not pick a winner for you.
16
+ - **Verification** — a checkable memory is compiled into a platform-aware guard and
17
+ re-checked on demand. `dsh-context-memory verify` exits non-zero only for a memory
18
+ whose guard ran and failed; a guard that cannot run is reported separately and is
19
+ never counted as a broken memory.
20
+ - **Panel** — a `Context memory` settings page listing memory, verification status,
21
+ conflicts, and provenance.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hj01857655
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # dsh-context-memory
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-context-memory)](https://www.npmjs.com/package/dsh-context-memory) [![CI](https://github.com/hj01857655/dsh-context-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/hj01857655/dsh-context-memory/actions/workflows/ci.yml)
4
+
5
+ Cross-session memory for your dsh agent — remembered, recalled, and re-verified instead of assumed.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ dsh plugin --profile web add dsh-context-memory
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ Every session with your agent starts from nothing. What you decided yesterday, which
16
+ approach failed, the constraint you explained once — all of it is gone, and you explain
17
+ it again.
18
+
19
+ This plugin keeps it, and — unlike a note file — re-checks the parts that can be checked.
20
+
21
+ - **Remember.** A memory carries provenance: which session, which step, whether it came
22
+ from you, the agent, or a plugin. "Why does the agent think it knows this" has an
23
+ answer.
24
+ - **Recall.** Retrieval is ranked (token overlap, recency, verification strength) instead
25
+ of pasting every memory into the context window. Each hit reports the breakdown of its
26
+ score, so a ranking is never something you have to trust.
27
+ - **Forget with a reason.** A memory is superseded, never deleted. The wrong memories are
28
+ the most valuable records in the log — they are the only thing that can explain a past
29
+ decision.
30
+ - **Conflict detection.** Two active memories about the same subject are reported as a
31
+ conflict. The plugin does not pick a winner; the log does not say which one is right,
32
+ and guessing would be the unfounded assertion this plugin exists to remove.
33
+ - **Verification.** A memory whose statement has a checkable shape is compiled into a
34
+ platform-aware guard and re-checked on demand. This is the part a notes file cannot do.
35
+
36
+ ## The point
37
+
38
+ A memory is a claim about the world. Storing it makes it *available*; it does not make it
39
+ *true*. Statements like "the file README.md exists" or "config.json contains pnpm" were
40
+ true when you said them and stop being true quietly.
41
+
42
+ So `verify` turns each checkable memory back into a check and runs it:
43
+
44
+ ```sh
45
+ dsh-context-memory verify
46
+ ```
47
+
48
+ ```
49
+ passed 4f2a9c1b8e30 the file package.json exists
50
+ violated 7d1e0a55c9f2 the file README.md contains Installation
51
+ uncheckable 2b8f31aa0456 we settled on pnpm because yarn hoisting broke the build
52
+ broken a91c4e77b0d3 the file scripts/release.sh exists
53
+
54
+ passed 1, violated 1, broken 1, uncheckable 1
55
+ ```
56
+
57
+ Four outcomes, and the distinction between them is the whole point:
58
+
59
+ | Outcome | Meaning |
60
+ |---|---|
61
+ | `passed` | The check ran and the memory holds. |
62
+ | `violated` | The check ran and the world disagrees with the memory. |
63
+ | `broken` | The check itself could not run — a typo, a missing binary. **Not** evidence about the memory. |
64
+ | `uncheckable` | No check could be compiled. The memory is still stored and recalled; it is just not verified. |
65
+
66
+ `verify` exits non-zero **only** for `violated`. A broken guard is a defect in the check,
67
+ and letting it turn a pipeline red is how a typo would get a true memory marked false —
68
+ after which you learn to ignore the output.
69
+
70
+ Nothing is ever marked as verified because it was stored, and a memory supplied by the
71
+ agent is never executed as a command: guards come from templates that only ever
72
+ interpolate quoted file paths and literal search strings.
73
+
74
+ ## CLI
75
+
76
+ ```sh
77
+ dsh-context-memory remember "the file package.json exists"
78
+ dsh-context-memory list
79
+ dsh-context-memory recall "how do we install"
80
+ dsh-context-memory conflicts
81
+ dsh-context-memory forget 4f2a9c1b8e30 "moved to bun"
82
+ dsh-context-memory verify # exit 1 only on a real violation
83
+ ```
84
+
85
+ ## Panel
86
+
87
+ A `Context memory` page inside Settings: active and superseded memories, verification
88
+ status per memory, conflicts, and provenance. The host registers
89
+ `GET /api/context-memory.panel` and rebuilds the payload from the log on every request,
90
+ so the panel shows what is on disk rather than what was in memory at startup. A headless
91
+ host has no web connection and simply skips the route.
92
+
93
+ ## Layout
94
+
95
+ | Path | Role |
96
+ |---|---|
97
+ | `src/index.ts` | Host half — `apply(ctx)`, exposes the `contextMemory` service |
98
+ | `src/memory.ts` | Fold the log, remember, supersede, conflicts, verify |
99
+ | `src/store.ts` | Append-only `.memory/events.jsonl` |
100
+ | `src/guard.ts` | Statement → platform-aware check; run it; classify pass / violated / broken |
101
+ | `src/recall.ts` | Relevance ranking with an explainable score |
102
+ | `src/identity.ts` | Id derived from the normalised subject, so repeats fold |
103
+ | `src/cli.ts` / `src/bin.ts` | Command-line entry, usable without dsh |
104
+ | `src/routes.ts` | Panel and verify routes on the host's web connection |
105
+ | `src/client/view.tsx` | Browser half — pure rendering, statically tested in Node |
106
+ | `src/client/index.tsx` | Browser half — fetch and settings registration |
107
+
108
+ ## Why the log is append-only
109
+
110
+ Current state is always a fold over the events, recomputed on each read. The
111
+ alternative — a mutable store — lets the last writer decide what was always true, and
112
+ "what did the agent believe on Tuesday, and why" becomes unanswerable. The log is the
113
+ evidence, so a torn trailing line after a crash is dropped (recoverable) while damage
114
+ anywhere else is reported rather than skipped.
115
+
116
+ ## Develop
117
+
118
+ ```sh
119
+ npm ci
120
+ npm run typecheck
121
+ npm run build
122
+ npm test
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,6 @@
1
+ # The host half loads through this patch. `insert` appends the plugin to the profile's
2
+ # bundle graph; the id must be unique within the profile or the loader reports a
3
+ # duplicate entry id and refuses to start.
4
+ - insert:
5
+ - id: dsh-context-memory
6
+ name: 'dsh-context-memory'
package/lib/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/lib/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './cli.js';
3
+ process.exitCode = run(process.argv.slice(2));
package/lib/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Command-line entry.
3
+ *
4
+ * The same modules the host uses, reachable without booting dsh — so what the plugin
5
+ * claims about a project can be checked independently, and `verify` can run in CI.
6
+ *
7
+ * @module context-memory/cli
8
+ */
9
+ export declare function run(argv: string[]): number;
package/lib/cli.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Command-line entry.
3
+ *
4
+ * The same modules the host uses, reachable without booting dsh — so what the plugin
5
+ * claims about a project can be checked independently, and `verify` can run in CI.
6
+ *
7
+ * @module context-memory/cli
8
+ */
9
+ import { parseArgs } from 'node:util';
10
+ import { ContextMemory } from './memory.js';
11
+ export function run(argv) {
12
+ const { positionals, values } = parseArgs({
13
+ args: argv,
14
+ allowPositionals: true,
15
+ options: {
16
+ subject: { type: 'string' },
17
+ kind: { type: 'string' },
18
+ source: { type: 'string' },
19
+ },
20
+ });
21
+ const memory = new ContextMemory(process.cwd());
22
+ const cmd = positionals[0] ?? 'list';
23
+ switch (cmd) {
24
+ case 'remember': {
25
+ const text = positionals.slice(1).join(' ');
26
+ if (text.trim().length === 0) {
27
+ console.log('Usage: dsh-context-memory remember "<statement>" [--subject <what it is about>]');
28
+ return 1;
29
+ }
30
+ // `--subject` is what makes a conflict expressible from the command line: two
31
+ // different statements about one thing stay separate records instead of folding.
32
+ const stored = memory.remember({
33
+ text,
34
+ ...(values['subject'] !== undefined ? { subject: String(values['subject']) } : {}),
35
+ ...(values['kind'] !== undefined ? { kind: String(values['kind']) } : {}),
36
+ ...(values['source'] !== undefined ? { source: String(values['source']) } : {}),
37
+ });
38
+ const guard = stored.guard ? ` (guard: ${stored.guard.command})` : ' (no check could be compiled)';
39
+ console.log(`Remembered ${stored.id}: ${stored.text}${guard}`);
40
+ // A memory with no executable check is recorded, and the caller is told plainly
41
+ // that nothing will verify it — rather than reporting a success that implies more.
42
+ return 0;
43
+ }
44
+ case 'list': {
45
+ const active = memory.active();
46
+ if (active.length === 0) {
47
+ console.log('No memories recorded.');
48
+ return 0;
49
+ }
50
+ for (const item of active) {
51
+ const mark = item.verification?.outcome === 'passed' ? '✓' : item.verification ? '✗' : '·';
52
+ console.log(`${mark} ${item.id} ${item.text}`);
53
+ }
54
+ return 0;
55
+ }
56
+ case 'recall': {
57
+ const query = positionals.slice(1).join(' ');
58
+ if (query.trim().length === 0) {
59
+ console.log('Usage: dsh-context-memory recall "<query>"');
60
+ return 1;
61
+ }
62
+ const hits = memory.recall(query);
63
+ if (hits.length === 0) {
64
+ console.log('Nothing relevant is remembered.');
65
+ return 0;
66
+ }
67
+ for (const hit of hits) {
68
+ console.log(`${hit.score.toFixed(3)} ${hit.memory.text}`);
69
+ console.log(` ${hit.why}`);
70
+ }
71
+ return 0;
72
+ }
73
+ case 'forget': {
74
+ const id = positionals[1] ?? '';
75
+ const reason = positionals.slice(2).join(' ');
76
+ if (id.length === 0 || reason.trim().length === 0) {
77
+ console.log('Usage: dsh-context-memory forget <id> "<reason>"');
78
+ console.log('A reason is required: a superseded memory is kept as the record of why it stopped being true.');
79
+ return 1;
80
+ }
81
+ const ok = memory.forget(id, reason);
82
+ console.log(ok ? `Superseded ${id}: ${reason}` : `No memory with id ${id}`);
83
+ return ok ? 0 : 1;
84
+ }
85
+ case 'conflicts': {
86
+ const conflicts = memory.conflicts();
87
+ if (conflicts.length === 0) {
88
+ console.log('No conflicts.');
89
+ return 0;
90
+ }
91
+ for (const conflict of conflicts) {
92
+ console.log(`Subject: ${conflict.subject}`);
93
+ for (const item of conflict.memories)
94
+ console.log(` - ${item.id}: ${item.text}`);
95
+ }
96
+ // A conflict is a question for the user, not a failure of this command.
97
+ return 0;
98
+ }
99
+ case 'verify': {
100
+ const summary = memory.verify();
101
+ for (const result of summary.results) {
102
+ console.log(`${result.outcome.padEnd(11)} ${result.id} ${result.text}`);
103
+ if (result.outcome === 'broken' && result.output.trim().length > 0) {
104
+ console.log(` the check could not run: ${result.output.trim().split('\n')[0]}`);
105
+ }
106
+ }
107
+ console.log(`\npassed ${summary.passed}, violated ${summary.violated}, broken ${summary.broken}, uncheckable ${summary.uncheckable}`);
108
+ if (summary.broken > 0) {
109
+ console.log('broken guards are a defect in the check, not a memory that is wrong — they do not fail this command');
110
+ }
111
+ return ContextMemory.exitCode(summary);
112
+ }
113
+ case 'help':
114
+ default:
115
+ console.log('Usage: dsh-context-memory <command>');
116
+ console.log('Commands:');
117
+ console.log(' remember "<statement>" record a memory and compile a check if possible');
118
+ console.log(' --subject <what it is about> make a later disagreeing statement a conflict');
119
+ console.log(' --kind <fact|decision|preference|pitfall>');
120
+ console.log(' --source <user|agent|plugin>');
121
+ console.log(' list active memories, with verification status');
122
+ console.log(' recall "<query>" rank memories by relevance');
123
+ console.log(' forget <id> "<reason>" retire a memory, keeping the reason');
124
+ console.log(' conflicts active memories that disagree');
125
+ console.log(' verify re-run every compiled check; exit 1 only on a real violation');
126
+ return 0;
127
+ }
128
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Browser half of dsh-context-memory — the registration shell.
3
+ *
4
+ * Declaring `inject` at module scope is required, not stylistic: without it the client
5
+ * loader cannot resolve the settings and connection services, and the half fails to
6
+ * register with an unhelpful error.
7
+ *
8
+ * @module context-memory/client
9
+ */
10
+ export declare const inject: string[];
11
+ export declare function apply(ctx: {
12
+ inject: (deps: string[], fn: (...services: unknown[]) => void) => void;
13
+ }): void;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Browser half of dsh-context-memory — the registration shell.
3
+ *
4
+ * Declaring `inject` at module scope is required, not stylistic: without it the client
5
+ * loader cannot resolve the settings and connection services, and the half fails to
6
+ * register with an unhelpful error.
7
+ *
8
+ * @module context-memory/client
9
+ */
10
+ import { renderPanel } from './view.js';
11
+ export const inject = ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-connection'];
12
+ export function apply(ctx) {
13
+ ctx.inject(inject, (settings, connection) => {
14
+ const s = settings;
15
+ const c = connection;
16
+ s.section('context-memory', {
17
+ title: 'Context memory',
18
+ render: async () => {
19
+ try {
20
+ const res = await c.fetch('/api/context-memory.panel');
21
+ const payload = await res.json();
22
+ return renderPanel(payload);
23
+ }
24
+ catch (error) {
25
+ // Say what failed rather than rendering an empty panel, which would read as
26
+ // "nothing is remembered" — a different and wrong answer.
27
+ return `<div class="context-memory-panel"><h2>Context memory</h2><p>Could not load memories: ${String(error)}</p></div>`;
28
+ }
29
+ },
30
+ });
31
+ });
32
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Browser half of dsh-context-memory — the view model.
3
+ *
4
+ * Rendering is kept here, separate from the fetch-and-register shell, so it can be
5
+ * statically rendered in Node during tests. The panel is a claim about what the agent
6
+ * remembers, so it is worth asserting on without a browser.
7
+ *
8
+ * @module context-memory/client/view
9
+ */
10
+ import type { MemoryState, PanelPayload } from '../types.js';
11
+ /**
12
+ * A memory's verification badge.
13
+ *
14
+ * Four states, not two, because "no check exists" and "the check could not run" are
15
+ * different answers from "the check failed" — collapsing them would report an
16
+ * unverifiable memory and a false one identically, which is the failure this plugin
17
+ * exists to avoid.
18
+ */
19
+ export declare function verificationLabel(memory: MemoryState): string;
20
+ export declare function renderPanel(payload: PanelPayload): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Browser half of dsh-context-memory — the view model.
3
+ *
4
+ * Rendering is kept here, separate from the fetch-and-register shell, so it can be
5
+ * statically rendered in Node during tests. The panel is a claim about what the agent
6
+ * remembers, so it is worth asserting on without a browser.
7
+ *
8
+ * @module context-memory/client/view
9
+ */
10
+ function escapeHtml(value) {
11
+ return value
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;');
16
+ }
17
+ /**
18
+ * A memory's verification badge.
19
+ *
20
+ * Four states, not two, because "no check exists" and "the check could not run" are
21
+ * different answers from "the check failed" — collapsing them would report an
22
+ * unverifiable memory and a false one identically, which is the failure this plugin
23
+ * exists to avoid.
24
+ */
25
+ export function verificationLabel(memory) {
26
+ if (!memory.guard)
27
+ return 'no check';
28
+ switch (memory.verification?.outcome) {
29
+ case 'passed': return 'checked, holds';
30
+ case 'violated': return 'checked, does not hold';
31
+ case 'broken': return 'check unusable';
32
+ default: return 'not yet checked';
33
+ }
34
+ }
35
+ function rows(memories) {
36
+ return memories
37
+ .map((memory) => {
38
+ const provenance = [
39
+ memory.source,
40
+ memory.sessionId ? `session ${memory.sessionId}` : null,
41
+ memory.step !== undefined ? `step ${memory.step}` : null,
42
+ ].filter(Boolean).join(' · ');
43
+ return `<tr><td><code>${escapeHtml(memory.id)}</code></td><td>${escapeHtml(memory.text)}</td><td>${escapeHtml(memory.kind)}</td><td>${escapeHtml(verificationLabel(memory))}</td><td>${escapeHtml(provenance)}</td></tr>`;
44
+ })
45
+ .join('');
46
+ }
47
+ export function renderPanel(payload) {
48
+ const { totals } = payload;
49
+ const summary = `<p class="memory-summary">${totals.active} active (${totals.verified} checked and holding, ${totals.unverified} not), ${totals.superseded} superseded, ${totals.withGuard} with a compiled check.</p>`;
50
+ const conflictBlock = payload.conflicts.length === 0
51
+ ? '<p>No conflicts.</p>'
52
+ : `<div class="memory-conflicts"><h3>Conflicts</h3><ul>${payload.conflicts
53
+ .map((conflict) => `<li><strong>${escapeHtml(conflict.subject)}</strong><ul>${conflict.memories
54
+ .map((memory) => `<li><code>${escapeHtml(memory.id)}</code> ${escapeHtml(memory.text)}</li>`)
55
+ .join('')}</ul></li>`)
56
+ .join('')}</ul></div>`;
57
+ const activeBlock = payload.active.length === 0
58
+ ? '<p>Nothing is remembered yet.</p>'
59
+ : `<table><thead><tr><th>Id</th><th>Memory</th><th>Kind</th><th>Status</th><th>From</th></tr></thead><tbody>${rows(payload.active)}</tbody></table>`;
60
+ const supersededBlock = payload.superseded.length === 0
61
+ ? ''
62
+ : `<details><summary>Superseded (${payload.superseded.length})</summary><table><thead><tr><th>Id</th><th>Memory</th><th>Kind</th><th>Status</th><th>From</th></tr></thead><tbody>${rows(payload.superseded)}</tbody></table></details>`;
63
+ return `<div class="context-memory-panel"><h2>Context memory</h2>${summary}${conflictBlock}<h3>Active</h3>${activeBlock}${supersededBlock}</div>`;
64
+ }
package/lib/guard.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Guard compilation and execution for memories.
3
+ *
4
+ * A memory is a claim, and this module is what decides whether the claim still holds.
5
+ * It keeps the same distinction dsh-verdict does, for the same reason: **a check that
6
+ * cannot run is not a memory that is wrong.** Conflating them would mean a mistyped
7
+ * path in a guard marks a perfectly good memory as false, and the user learns to
8
+ * ignore the output.
9
+ *
10
+ * Recognition is narrow on purpose. Only statement shapes whose checkable predicate is
11
+ * unambiguous get a guard; everything else stays uncheckable and says so. Emitting an
12
+ * approximate command would produce a check that exits 0 while verifying nothing, and
13
+ * every later "verified" would be a lie.
14
+ *
15
+ * There is deliberately **no** shape that runs a command taken from memory text. A
16
+ * memory can be written by the agent itself, so treating its content as an executable
17
+ * command is an injection path — the templates below only ever interpolate quoted file
18
+ * paths and literal search strings.
19
+ *
20
+ * @module context-memory/guard
21
+ */
22
+ import type { Guard, GuardOutcome } from './types.js';
23
+ /**
24
+ * Compile a memory statement into an executable check.
25
+ *
26
+ * Commands are emitted for the platform in use. `process.platform` rather than a
27
+ * hardcoded POSIX spelling: `test -e` on Windows would leave every compiled memory
28
+ * permanently unrunnable, so verification would report on nothing while looking busy.
29
+ */
30
+ export declare function compileGuard(text: string, platform?: NodeJS.Platform): Guard | undefined;
31
+ /**
32
+ * Quote a string so a POSIX shell treats it as one literal argument.
33
+ *
34
+ * Single quotes make every character literal; the only character that cannot appear
35
+ * inside them is the quote itself, escaped by closing, escaping, and reopening.
36
+ */
37
+ export declare function shellQuote(value: string): string;
38
+ /**
39
+ * Run one guard to completion.
40
+ *
41
+ * `spawnSync` with a shell so compound commands work as written. No timeout: a guard
42
+ * that hangs is a hang, and killing it after N seconds would turn "slow" into an
43
+ * outcome that varies with machine load.
44
+ */
45
+ export declare function runGuard(id: string, guard: Guard, cwd: string, now?: Date): {
46
+ id: string;
47
+ outcome: GuardOutcome;
48
+ exitCode: null;
49
+ output: string;
50
+ at: string;
51
+ } | {
52
+ id: string;
53
+ outcome: GuardOutcome;
54
+ exitCode: number;
55
+ output: string;
56
+ at: string;
57
+ };
58
+ /**
59
+ * Classify an exit code into an outcome.
60
+ *
61
+ * Two conventions mean "the command does not exist": POSIX shells use 127, cmd.exe
62
+ * uses 9009. Both are recognised, along with the text shells print instead of a
63
+ * distinctive code when the failure happens inside the shell. Without this, a
64
+ * misspelled path would look like a memory being wrong about the world.
65
+ */
66
+ export declare function classify(code: number, output?: string): GuardOutcome;
package/lib/guard.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Guard compilation and execution for memories.
3
+ *
4
+ * A memory is a claim, and this module is what decides whether the claim still holds.
5
+ * It keeps the same distinction dsh-verdict does, for the same reason: **a check that
6
+ * cannot run is not a memory that is wrong.** Conflating them would mean a mistyped
7
+ * path in a guard marks a perfectly good memory as false, and the user learns to
8
+ * ignore the output.
9
+ *
10
+ * Recognition is narrow on purpose. Only statement shapes whose checkable predicate is
11
+ * unambiguous get a guard; everything else stays uncheckable and says so. Emitting an
12
+ * approximate command would produce a check that exits 0 while verifying nothing, and
13
+ * every later "verified" would be a lie.
14
+ *
15
+ * There is deliberately **no** shape that runs a command taken from memory text. A
16
+ * memory can be written by the agent itself, so treating its content as an executable
17
+ * command is an injection path — the templates below only ever interpolate quoted file
18
+ * paths and literal search strings.
19
+ *
20
+ * @module context-memory/guard
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+ /** Enough output to diagnose, small enough to keep inline in the event log. */
24
+ const OUTPUT_LIMIT = 2000;
25
+ /**
26
+ * Compile a memory statement into an executable check.
27
+ *
28
+ * Commands are emitted for the platform in use. `process.platform` rather than a
29
+ * hardcoded POSIX spelling: `test -e` on Windows would leave every compiled memory
30
+ * permanently unrunnable, so verification would report on nothing while looking busy.
31
+ */
32
+ export function compileGuard(text, platform = process.platform) {
33
+ const normalized = text.trim().replace(/\s+/g, ' ');
34
+ const posix = platform !== 'win32';
35
+ const operand = (value) => (posix ? shellQuote(value) : `"${value.replace(/"/g, '\\"')}"`);
36
+ // "the file X must not contain Y" — checked before the positive form so that the
37
+ // negated sentence is never read as an assertion that Y is present.
38
+ const fileLacks = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:must not|should not|never) (?:contain|mention|include) `?([^\s`]+)`?$/i.exec(normalized);
39
+ if (fileLacks?.[1] && fileLacks[2]) {
40
+ const [file, needle] = [fileLacks[1], fileLacks[2]];
41
+ // A quote inside the operand cannot survive cmd.exe's own quoting layer; refusing
42
+ // beats emitting a command whose meaning differs from the sentence.
43
+ if (!posix && needle.includes('"'))
44
+ return undefined;
45
+ return {
46
+ command: posix
47
+ ? `! grep -qF ${shellQuote(needle)} ${shellQuote(file)}`
48
+ : `findstr /c:${operand(needle)} ${operand(file)} >nul 2>nul && exit 1`,
49
+ source: 'template',
50
+ };
51
+ }
52
+ // "the file X contains Y" / "X mentions Y"
53
+ const fileHas = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:contains|mentions|includes) `?([^\s`]+)`?$/i.exec(normalized);
54
+ if (fileHas?.[1] && fileHas[2]) {
55
+ const [file, needle] = [fileHas[1], fileHas[2]];
56
+ if (!posix && needle.includes('"'))
57
+ return undefined;
58
+ return {
59
+ command: posix
60
+ ? `grep -qF ${shellQuote(needle)} ${shellQuote(file)}`
61
+ : `findstr /c:${operand(needle)} ${operand(file)} >nul`,
62
+ source: 'template',
63
+ };
64
+ }
65
+ // "the file X exists" / "X exists" / "X is at <path>" is the same predicate.
66
+ const fileExists = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:exists|is present)$/i.exec(normalized)
67
+ ?? /^`?([^\s`]+)`? exists$/i.exec(normalized);
68
+ if (fileExists?.[1]) {
69
+ const target = operand(fileExists[1]);
70
+ return {
71
+ command: posix ? `test -f ${target}` : `cmd /c if not exist ${target} exit 1`,
72
+ source: 'template',
73
+ };
74
+ }
75
+ // "there is no file X" / "never commit X"
76
+ const fileAbsent = /^(?:there is no|there are no|no) (?:file )?`?([^\s`]+)`?$/i.exec(normalized)
77
+ ?? /^(?:never|do not|don't) (?:create|add|commit|write|touch) (?:the )?(?:file )?`?([^\s`]+)`?$/i.exec(normalized);
78
+ if (fileAbsent?.[1]) {
79
+ const target = operand(fileAbsent[1]);
80
+ return {
81
+ command: posix ? `! test -e ${target}` : `cmd /c if exist ${target} exit 1`,
82
+ source: 'template',
83
+ };
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * Quote a string so a POSIX shell treats it as one literal argument.
89
+ *
90
+ * Single quotes make every character literal; the only character that cannot appear
91
+ * inside them is the quote itself, escaped by closing, escaping, and reopening.
92
+ */
93
+ export function shellQuote(value) {
94
+ return `'${value.replace(/'/g, `'\\''`)}'`;
95
+ }
96
+ /**
97
+ * Run one guard to completion.
98
+ *
99
+ * `spawnSync` with a shell so compound commands work as written. No timeout: a guard
100
+ * that hangs is a hang, and killing it after N seconds would turn "slow" into an
101
+ * outcome that varies with machine load.
102
+ */
103
+ export function runGuard(id, guard, cwd, now = new Date()) {
104
+ const at = now.toISOString();
105
+ let proc;
106
+ try {
107
+ proc = spawnSync(guard.command, { cwd, shell: true, encoding: 'utf8' });
108
+ }
109
+ catch (error) {
110
+ return { id, outcome: 'broken', exitCode: null, output: String(error), at };
111
+ }
112
+ const output = `${proc.stdout ?? ''}${proc.stderr ?? ''}`.slice(0, OUTPUT_LIMIT);
113
+ if (proc.error) {
114
+ return { id, outcome: 'broken', exitCode: null, output: `${output}${proc.error.message}`.slice(0, OUTPUT_LIMIT), at };
115
+ }
116
+ const code = proc.status;
117
+ if (code === null) {
118
+ return { id, outcome: 'broken', exitCode: null, output: output || 'terminated by signal', at };
119
+ }
120
+ return { id, outcome: classify(code, output), exitCode: code, output, at };
121
+ }
122
+ /**
123
+ * Classify an exit code into an outcome.
124
+ *
125
+ * Two conventions mean "the command does not exist": POSIX shells use 127, cmd.exe
126
+ * uses 9009. Both are recognised, along with the text shells print instead of a
127
+ * distinctive code when the failure happens inside the shell. Without this, a
128
+ * misspelled path would look like a memory being wrong about the world.
129
+ */
130
+ export function classify(code, output = '') {
131
+ if (code === 0)
132
+ return 'passed';
133
+ if (code === 127 || code === 9009)
134
+ return 'broken';
135
+ if (/command not found|is not recognized|No such file or directory|cannot open/i.test(output)) {
136
+ return 'broken';
137
+ }
138
+ return 'violated';
139
+ }