@jbarket/loomai 0.5.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 +475 -0
- package/LICENSE +661 -0
- package/README.md +702 -0
- package/assets/demo.cast +396 -0
- package/assets/skill/SKILL.md +192 -0
- package/dist/backends/embedding-runtime.d.ts +39 -0
- package/dist/backends/embedding-runtime.js +265 -0
- package/dist/backends/episodes.d.ts +36 -0
- package/dist/backends/episodes.js +126 -0
- package/dist/backends/fastembed.d.ts +18 -0
- package/dist/backends/fastembed.js +95 -0
- package/dist/backends/glob.d.ts +10 -0
- package/dist/backends/glob.js +16 -0
- package/dist/backends/index.d.ts +6 -0
- package/dist/backends/index.js +78 -0
- package/dist/backends/migrations.d.ts +38 -0
- package/dist/backends/migrations.js +157 -0
- package/dist/backends/mmr.d.ts +45 -0
- package/dist/backends/mmr.js +82 -0
- package/dist/backends/proposals.d.ts +65 -0
- package/dist/backends/proposals.js +182 -0
- package/dist/backends/recall-log.d.ts +42 -0
- package/dist/backends/recall-log.js +143 -0
- package/dist/backends/retry.d.ts +29 -0
- package/dist/backends/retry.js +60 -0
- package/dist/backends/salience.d.ts +57 -0
- package/dist/backends/salience.js +186 -0
- package/dist/backends/sqlite-knowledge.d.ts +37 -0
- package/dist/backends/sqlite-knowledge.js +788 -0
- package/dist/backends/sqlite-vec.d.ts +64 -0
- package/dist/backends/sqlite-vec.js +737 -0
- package/dist/backends/ttl.d.ts +24 -0
- package/dist/backends/ttl.js +50 -0
- package/dist/backends/types.d.ts +562 -0
- package/dist/backends/types.js +8 -0
- package/dist/blocks/harness.d.ts +45 -0
- package/dist/blocks/harness.js +142 -0
- package/dist/blocks/model.d.ts +4 -0
- package/dist/blocks/model.js +56 -0
- package/dist/blocks/types.d.ts +40 -0
- package/dist/blocks/types.js +34 -0
- package/dist/categories.d.ts +16 -0
- package/dist/categories.js +29 -0
- package/dist/cli/args.d.ts +21 -0
- package/dist/cli/args.js +47 -0
- package/dist/cli/bootstrap.d.ts +2 -0
- package/dist/cli/bootstrap.js +214 -0
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +206 -0
- package/dist/cli/forget.d.ts +2 -0
- package/dist/cli/forget.js +97 -0
- package/dist/cli/harness.d.ts +2 -0
- package/dist/cli/harness.js +91 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +127 -0
- package/dist/cli/inject.d.ts +2 -0
- package/dist/cli/inject.js +298 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +137 -0
- package/dist/cli/io.d.ts +14 -0
- package/dist/cli/io.js +63 -0
- package/dist/cli/knowledge.d.ts +2 -0
- package/dist/cli/knowledge.js +216 -0
- package/dist/cli/memory.d.ts +2 -0
- package/dist/cli/memory.js +608 -0
- package/dist/cli/migrate.d.ts +2 -0
- package/dist/cli/migrate.js +116 -0
- package/dist/cli/recall.d.ts +2 -0
- package/dist/cli/recall.js +91 -0
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +111 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +71 -0
- package/dist/cli/subcommands.d.ts +7 -0
- package/dist/cli/subcommands.js +11 -0
- package/dist/cli/tui/multi-select.d.ts +53 -0
- package/dist/cli/tui/multi-select.js +116 -0
- package/dist/cli/update-identity.d.ts +2 -0
- package/dist/cli/update-identity.js +90 -0
- package/dist/cli/update.d.ts +2 -0
- package/dist/cli/update.js +87 -0
- package/dist/cli/wake.d.ts +2 -0
- package/dist/cli/wake.js +53 -0
- package/dist/clients.d.ts +8 -0
- package/dist/clients.js +63 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +160 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +75 -0
- package/dist/injection/harnesses.d.ts +17 -0
- package/dist/injection/harnesses.js +51 -0
- package/dist/injection/render.d.ts +17 -0
- package/dist/injection/render.js +50 -0
- package/dist/injection/writer.d.ts +25 -0
- package/dist/injection/writer.js +118 -0
- package/dist/install/harnesses.d.ts +26 -0
- package/dist/install/harnesses.js +90 -0
- package/dist/install/names.d.ts +16 -0
- package/dist/install/names.js +36 -0
- package/dist/install/render.d.ts +18 -0
- package/dist/install/render.js +42 -0
- package/dist/install/skill-source.d.ts +1 -0
- package/dist/install/skill-source.js +11 -0
- package/dist/path-safety.d.ts +19 -0
- package/dist/path-safety.js +62 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +571 -0
- package/dist/tools/append-maintenance-log.d.ts +52 -0
- package/dist/tools/append-maintenance-log.js +103 -0
- package/dist/tools/archive.d.ts +2 -0
- package/dist/tools/archive.js +21 -0
- package/dist/tools/bootstrap.d.ts +11 -0
- package/dist/tools/bootstrap.js +161 -0
- package/dist/tools/dossier.d.ts +1 -0
- package/dist/tools/dossier.js +102 -0
- package/dist/tools/eurorack-import.d.ts +47 -0
- package/dist/tools/eurorack-import.js +199 -0
- package/dist/tools/find-similar.d.ts +3 -0
- package/dist/tools/find-similar.js +26 -0
- package/dist/tools/forget.d.ts +6 -0
- package/dist/tools/forget.js +70 -0
- package/dist/tools/harness.d.ts +18 -0
- package/dist/tools/harness.js +54 -0
- package/dist/tools/identity-scaffold.d.ts +22 -0
- package/dist/tools/identity-scaffold.js +142 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +226 -0
- package/dist/tools/knowledge-archive.d.ts +5 -0
- package/dist/tools/knowledge-archive.js +24 -0
- package/dist/tools/knowledge-history.d.ts +6 -0
- package/dist/tools/knowledge-history.js +55 -0
- package/dist/tools/knowledge-maintain.d.ts +9 -0
- package/dist/tools/knowledge-maintain.js +113 -0
- package/dist/tools/knowledge-merge.d.ts +8 -0
- package/dist/tools/knowledge-merge.js +40 -0
- package/dist/tools/knowledge-move.d.ts +10 -0
- package/dist/tools/knowledge-move.js +48 -0
- package/dist/tools/knowledge-purge.d.ts +5 -0
- package/dist/tools/knowledge-purge.js +20 -0
- package/dist/tools/knowledge-recall.d.ts +20 -0
- package/dist/tools/knowledge-recall.js +142 -0
- package/dist/tools/knowledge-restore.d.ts +4 -0
- package/dist/tools/knowledge-restore.js +24 -0
- package/dist/tools/knowledge-supersede.d.ts +6 -0
- package/dist/tools/knowledge-supersede.js +36 -0
- package/dist/tools/knowledge-verify.d.ts +8 -0
- package/dist/tools/knowledge-verify.js +39 -0
- package/dist/tools/knowledge-write.d.ts +25 -0
- package/dist/tools/knowledge-write.js +87 -0
- package/dist/tools/memory-audit.d.ts +3 -0
- package/dist/tools/memory-audit.js +47 -0
- package/dist/tools/memory-list.d.ts +4 -0
- package/dist/tools/memory-list.js +23 -0
- package/dist/tools/prune.d.ts +4 -0
- package/dist/tools/prune.js +28 -0
- package/dist/tools/recall.d.ts +4 -0
- package/dist/tools/recall.js +24 -0
- package/dist/tools/remember.d.ts +10 -0
- package/dist/tools/remember.js +43 -0
- package/dist/tools/restore.d.ts +2 -0
- package/dist/tools/restore.js +20 -0
- package/dist/tools/update-identity.d.ts +34 -0
- package/dist/tools/update-identity.js +194 -0
- package/dist/tools/update.d.ts +2 -0
- package/dist/tools/update.js +16 -0
- package/dist/transport/guards.d.ts +35 -0
- package/dist/transport/guards.js +107 -0
- package/dist/transport/http-server.d.ts +17 -0
- package/dist/transport/http-server.js +198 -0
- package/dist/transport/mcp-app-hello.d.ts +3 -0
- package/dist/transport/mcp-app-hello.js +66 -0
- package/dist/widgets/display-modes.html +131 -0
- package/dist/widgets/hello.html +138 -0
- package/dist/widgets/proposals.html +131 -0
- package/dist/widgets/top-of-mind.html +131 -0
- package/package.json +73 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom update — modify an existing memory.
|
|
3
|
+
*/
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { update } from '../tools/update.js';
|
|
6
|
+
import { createBackend } from '../backends/index.js';
|
|
7
|
+
import { assertStackVersionCompatible } from '../config.js';
|
|
8
|
+
import { extractGlobalFlags, resolveEnv } from './args.js';
|
|
9
|
+
import { readBody, renderJson } from './io.js';
|
|
10
|
+
const USAGE = `Usage: loom update <ref> [options]
|
|
11
|
+
loom update --category <cat> --title <exact> [options]
|
|
12
|
+
|
|
13
|
+
Updates content (from stdin or \$EDITOR) and/or metadata on an existing
|
|
14
|
+
memory.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--category <name> Identify by category (with --title)
|
|
18
|
+
--title <exact> Identify by title (with --category)
|
|
19
|
+
--json Emit UpdateResult
|
|
20
|
+
--context-dir <path> Agent context dir
|
|
21
|
+
--help, -h Show this help
|
|
22
|
+
`;
|
|
23
|
+
export async function run(argv, io) {
|
|
24
|
+
const { flags: global, rest } = extractGlobalFlags(argv);
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = parseArgs({
|
|
28
|
+
args: rest,
|
|
29
|
+
options: {
|
|
30
|
+
category: { type: 'string' },
|
|
31
|
+
title: { type: 'string' },
|
|
32
|
+
help: { type: 'boolean', short: 'h' },
|
|
33
|
+
},
|
|
34
|
+
strict: true,
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
io.stderr(`${err.message}\n${USAGE}`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
if (parsed.values.help) {
|
|
43
|
+
io.stdout(USAGE);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const ref = parsed.positionals[0];
|
|
47
|
+
const hasIdentifier = ref || (parsed.values.category && parsed.values.title);
|
|
48
|
+
if (!hasIdentifier) {
|
|
49
|
+
io.stderr(`Provide a <ref> or --category+--title.\n${USAGE}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const env = resolveEnv(global, io.env);
|
|
53
|
+
try {
|
|
54
|
+
assertStackVersionCompatible(env.contextDir);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
io.stderr(`${err.message}\n`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await readBody(io, 'update');
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
io.stderr(`${err.message}\n`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
if (!body) {
|
|
69
|
+
io.stderr(`body cannot be empty\n`);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
const input = {
|
|
73
|
+
ref,
|
|
74
|
+
category: parsed.values.category,
|
|
75
|
+
title: parsed.values.title,
|
|
76
|
+
content: body,
|
|
77
|
+
};
|
|
78
|
+
if (env.json) {
|
|
79
|
+
const backend = createBackend(env.contextDir);
|
|
80
|
+
const result = await backend.update(input);
|
|
81
|
+
renderJson(io, result);
|
|
82
|
+
return result.updated ? 0 : 3;
|
|
83
|
+
}
|
|
84
|
+
const text = await update(env.contextDir, input);
|
|
85
|
+
io.stdout(text.endsWith('\n') ? text : text + '\n');
|
|
86
|
+
return /not found/i.test(text) ? 3 : 0;
|
|
87
|
+
}
|
package/dist/cli/wake.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom wake — prints agent identity markdown to stdout.
|
|
3
|
+
*/
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { loadIdentity } from '../tools/identity.js';
|
|
6
|
+
import { assertStackVersionCompatible } from '../config.js';
|
|
7
|
+
import { extractGlobalFlags, resolveEnv } from './args.js';
|
|
8
|
+
const USAGE = `Usage: loom wake [options]
|
|
9
|
+
|
|
10
|
+
Prints the agent's wake output (identity, preferences, self-model,
|
|
11
|
+
harness manifest, model manifest) to stdout.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--project <name> Load projects/<name>.md as additional context
|
|
15
|
+
--context-dir <path> Agent context dir
|
|
16
|
+
--client <name> Harness adapter hint
|
|
17
|
+
--model <name> Model manifest hint
|
|
18
|
+
--help, -h Show this help
|
|
19
|
+
`;
|
|
20
|
+
export async function run(argv, io) {
|
|
21
|
+
const { flags: global, rest } = extractGlobalFlags(argv);
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = parseArgs({
|
|
25
|
+
args: rest,
|
|
26
|
+
options: {
|
|
27
|
+
project: { type: 'string' },
|
|
28
|
+
help: { type: 'boolean', short: 'h' },
|
|
29
|
+
},
|
|
30
|
+
strict: true,
|
|
31
|
+
allowPositionals: false,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
io.stderr(`${err.message}\n${USAGE}`);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
if (parsed.values.help) {
|
|
39
|
+
io.stdout(USAGE);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const env = resolveEnv(global, io.env);
|
|
43
|
+
try {
|
|
44
|
+
assertStackVersionCompatible(env.contextDir);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
io.stderr(`${err.message}\n`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
const md = await loadIdentity(env.contextDir, parsed.values.project, env.client, env.model);
|
|
51
|
+
io.stdout(md.endsWith('\n') ? md : md + '\n');
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const TOOLS: string[];
|
|
2
|
+
export declare function getBuiltInAdapter(client: string): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Load the adapter for a given client name.
|
|
5
|
+
* Checks <contextDir>/clients/<client>.md first (user override),
|
|
6
|
+
* then falls back to the built-in adapter.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadClientAdapter(contextDir: string, client: string): Promise<string | null>;
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client adapters — runtime-specific context injected into identity loads.
|
|
3
|
+
*
|
|
4
|
+
* When `identity` is called with a `client` param (or LOOM_CLIENT env var),
|
|
5
|
+
* the corresponding adapter is appended to the identity response. This tells
|
|
6
|
+
* the agent how loom tools are named in its specific runtime.
|
|
7
|
+
*
|
|
8
|
+
* Built-in adapters ship with loom. User overrides live in
|
|
9
|
+
* `<contextDir>/clients/<client>.md` and take precedence.
|
|
10
|
+
*
|
|
11
|
+
* Supported clients: claude-code, gemini-cli. Any other runtime can drop a
|
|
12
|
+
* user override at `<contextDir>/clients/<name>.md`.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { assertSafePathSegment } from './path-safety.js';
|
|
17
|
+
// ─── Tool list ────────────────────────────────────────────────────────────────
|
|
18
|
+
// Canonical tool names — used to generate the prefix-specific list per client
|
|
19
|
+
export const TOOLS = [
|
|
20
|
+
'identity', 'dossier', 'remember', 'recall', 'update', 'forget',
|
|
21
|
+
'memory_list', 'memory_prune', 'find_similar', 'memory_audit', 'episodes',
|
|
22
|
+
'memory_archive', 'memory_restore',
|
|
23
|
+
'memory_propose', 'memory_proposals', 'memory_ratify', 'memory_reject',
|
|
24
|
+
'update_identity', 'bootstrap', 'harness_init', 'harness_describe',
|
|
25
|
+
'knowledge_write', 'knowledge_recall', 'knowledge_maintain',
|
|
26
|
+
'knowledge_archive', 'knowledge_restore', 'knowledge_supersede', 'knowledge_move', 'knowledge_merge',
|
|
27
|
+
'knowledge_purge', 'knowledge_verify', 'knowledge_history',
|
|
28
|
+
];
|
|
29
|
+
function toolList(prefix) {
|
|
30
|
+
return TOOLS.map((t) => `\`${prefix}${t}\``).join(', ');
|
|
31
|
+
}
|
|
32
|
+
// ─── Built-in adapters ────────────────────────────────────────────────────────
|
|
33
|
+
const ADAPTERS = {
|
|
34
|
+
'claude-code': `## Runtime: Claude Code
|
|
35
|
+
|
|
36
|
+
You are running in Claude Code (Anthropic CLI). Loom tools use double-underscore prefix:
|
|
37
|
+
${toolList('mcp__loom__')}`,
|
|
38
|
+
'gemini-cli': `## Runtime: Gemini CLI
|
|
39
|
+
|
|
40
|
+
You are running in Gemini CLI (Google). Loom tools use double-underscore prefix:
|
|
41
|
+
${toolList('mcp__loom__')}`,
|
|
42
|
+
};
|
|
43
|
+
// ─── Loader ──────────────────────────────────────────────────────────────────
|
|
44
|
+
export function getBuiltInAdapter(client) {
|
|
45
|
+
return ADAPTERS[client] ?? null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load the adapter for a given client name.
|
|
49
|
+
* Checks <contextDir>/clients/<client>.md first (user override),
|
|
50
|
+
* then falls back to the built-in adapter.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadClientAdapter(contextDir, client) {
|
|
53
|
+
assertSafePathSegment(client, 'client name');
|
|
54
|
+
const overridePath = join(contextDir, 'clients', `${client}.md`);
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(overridePath, 'utf-8');
|
|
57
|
+
return content.trim();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// no override, use built-in
|
|
61
|
+
}
|
|
62
|
+
return getBuiltInAdapter(client);
|
|
63
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single canonical helper for the default context path. Every call
|
|
3
|
+
* site that needs `~/.config/loom/default` must go through this —
|
|
4
|
+
* no more duplicated literals in config.ts / args.ts / doctor.ts.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveDefaultContextPath(home?: string): string;
|
|
7
|
+
export declare function resolveContextDir(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Throws if the context dir is the default fallback path AND it has no
|
|
10
|
+
* IDENTITY.md. This is the "fail loud instead of silent fallback" guard —
|
|
11
|
+
* a blank-identity boot is only permitted when the caller explicitly opted
|
|
12
|
+
* in via LOOM_CONTEXT_DIR or --context-dir pointing at a real (even empty)
|
|
13
|
+
* dir. A missing/dangling/empty default must never silently serve a fresh-
|
|
14
|
+
* agent skeleton.
|
|
15
|
+
*
|
|
16
|
+
* @param contextDir The resolved context dir to validate.
|
|
17
|
+
* @param defaultPath Override the default path (used in unit tests to avoid
|
|
18
|
+
* depending on the real homedir).
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertContextBootable(contextDir: string, defaultPath?: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the loom repo root from the running module location.
|
|
23
|
+
* Works from both src/ (dev via tsx) and dist/ (production).
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveRepoRoot(): string;
|
|
26
|
+
export declare function resolveSqliteDbPath(contextDir: string): string;
|
|
27
|
+
export declare function resolveKnowledgeDbPath(contextDir: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Local-only observation logs (e.g. `telemetry/recall.jsonl`). Lives inside
|
|
30
|
+
* the context dir so it travels with the agent and never leaves the machine.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveTelemetryDir(contextDir: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Assert that the knowledge database path does not co-locate with the memory
|
|
35
|
+
* database path. If they resolve to the same file (or inode), throw.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertKnowledgePathsNotCoLocated(contextDir: string): void;
|
|
38
|
+
export declare function resolveFastEmbedModel(): string;
|
|
39
|
+
export declare function resolveFastEmbedCacheDir(): string | undefined;
|
|
40
|
+
/** The stack schema version this loom build understands. */
|
|
41
|
+
export declare const CURRENT_STACK_VERSION = 2;
|
|
42
|
+
/** The filename at the stack root that records the on-disk schema version. */
|
|
43
|
+
export declare const STACK_VERSION_FILE = "LOOM_STACK_VERSION";
|
|
44
|
+
/**
|
|
45
|
+
* Read the stack version stamp at `<contextDir>/LOOM_STACK_VERSION`.
|
|
46
|
+
* Returns null if the file is missing, or NaN if the content doesn't parse.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readStackVersion(contextDir: string): number | null;
|
|
49
|
+
/**
|
|
50
|
+
* Lazy-write the current stack version if the stamp is missing. Does not
|
|
51
|
+
* overwrite an existing file; the caller is responsible for validating
|
|
52
|
+
* (and refusing) versions ahead of CURRENT_STACK_VERSION.
|
|
53
|
+
*/
|
|
54
|
+
export declare function ensureStackVersion(contextDir: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Refuse to operate against a stack at a higher version than this build
|
|
57
|
+
* understands; stamp the current version if the file is missing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertStackVersionCompatible(contextDir: string): void;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration — resolves directories and backend settings for a
|
|
3
|
+
* given loom agent.
|
|
4
|
+
*
|
|
5
|
+
* The context directory is the agent's brain — everything
|
|
6
|
+
* agent-specific lives here:
|
|
7
|
+
* IDENTITY.md — the terminal creed (who this agent is)
|
|
8
|
+
* preferences.md — user preferences and working style
|
|
9
|
+
* self-model.md — agent capability tracking
|
|
10
|
+
* memories.db — sqlite + sqlite-vec memory store (pursuits live here too, as category=pursuit)
|
|
11
|
+
*
|
|
12
|
+
* Context dir resolution order:
|
|
13
|
+
* 1. LOOM_CONTEXT_DIR environment variable
|
|
14
|
+
* 2. --context-dir CLI argument
|
|
15
|
+
* 3. ~/.config/loom/default (fallback)
|
|
16
|
+
*/
|
|
17
|
+
import { resolve, dirname } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
21
|
+
/**
|
|
22
|
+
* The single canonical helper for the default context path. Every call
|
|
23
|
+
* site that needs `~/.config/loom/default` must go through this —
|
|
24
|
+
* no more duplicated literals in config.ts / args.ts / doctor.ts.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDefaultContextPath(home) {
|
|
27
|
+
return resolve(home ?? homedir(), '.config', 'loom', 'default');
|
|
28
|
+
}
|
|
29
|
+
export function resolveContextDir() {
|
|
30
|
+
if (process.env.LOOM_CONTEXT_DIR) {
|
|
31
|
+
return resolve(process.env.LOOM_CONTEXT_DIR);
|
|
32
|
+
}
|
|
33
|
+
const argIdx = process.argv.indexOf('--context-dir');
|
|
34
|
+
if (argIdx !== -1 && process.argv[argIdx + 1]) {
|
|
35
|
+
return resolve(process.argv[argIdx + 1]);
|
|
36
|
+
}
|
|
37
|
+
return resolveDefaultContextPath();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Throws if the context dir is the default fallback path AND it has no
|
|
41
|
+
* IDENTITY.md. This is the "fail loud instead of silent fallback" guard —
|
|
42
|
+
* a blank-identity boot is only permitted when the caller explicitly opted
|
|
43
|
+
* in via LOOM_CONTEXT_DIR or --context-dir pointing at a real (even empty)
|
|
44
|
+
* dir. A missing/dangling/empty default must never silently serve a fresh-
|
|
45
|
+
* agent skeleton.
|
|
46
|
+
*
|
|
47
|
+
* @param contextDir The resolved context dir to validate.
|
|
48
|
+
* @param defaultPath Override the default path (used in unit tests to avoid
|
|
49
|
+
* depending on the real homedir).
|
|
50
|
+
*/
|
|
51
|
+
export function assertContextBootable(contextDir, defaultPath = resolveDefaultContextPath()) {
|
|
52
|
+
if (contextDir !== defaultPath)
|
|
53
|
+
return;
|
|
54
|
+
if (!existsSync(resolve(contextDir, 'IDENTITY.md'))) {
|
|
55
|
+
throw new Error('no loom context configured — refusing to serve a blank identity. ' +
|
|
56
|
+
'Set LOOM_CONTEXT_DIR or run `loom bootstrap` to initialize an agent.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the loom repo root from the running module location.
|
|
61
|
+
* Works from both src/ (dev via tsx) and dist/ (production).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveRepoRoot() {
|
|
64
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
65
|
+
return resolve(dirname(thisFile), '..');
|
|
66
|
+
}
|
|
67
|
+
export function resolveSqliteDbPath(contextDir) {
|
|
68
|
+
return process.env.LOOM_SQLITE_DB_PATH ?? resolve(contextDir, 'memories.db');
|
|
69
|
+
}
|
|
70
|
+
export function resolveKnowledgeDbPath(contextDir) {
|
|
71
|
+
return process.env.LOOM_KNOWLEDGE_DB_PATH ?? resolve(contextDir, 'knowledge.db');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Local-only observation logs (e.g. `telemetry/recall.jsonl`). Lives inside
|
|
75
|
+
* the context dir so it travels with the agent and never leaves the machine.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveTelemetryDir(contextDir) {
|
|
78
|
+
return resolve(contextDir, 'telemetry');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Assert that the knowledge database path does not co-locate with the memory
|
|
82
|
+
* database path. If they resolve to the same file (or inode), throw.
|
|
83
|
+
*/
|
|
84
|
+
export function assertKnowledgePathsNotCoLocated(contextDir) {
|
|
85
|
+
const memPath = resolveSqliteDbPath(contextDir);
|
|
86
|
+
const knPath = resolveKnowledgeDbPath(contextDir);
|
|
87
|
+
if (memPath === knPath) {
|
|
88
|
+
throw new Error(`Knowledge database path is the same as memory database path (${memPath}). ` +
|
|
89
|
+
`This violates the isolation invariant. Set LOOM_SQLITE_DB_PATH or LOOM_KNOWLEDGE_DB_PATH to different files.`);
|
|
90
|
+
}
|
|
91
|
+
// Also check inode-level co-location (hard links)
|
|
92
|
+
let memStat = null;
|
|
93
|
+
let knStat = null;
|
|
94
|
+
try {
|
|
95
|
+
memStat = statSync(memPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// File doesn't exist yet
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
knStat = statSync(knPath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// File doesn't exist yet
|
|
105
|
+
}
|
|
106
|
+
if (memStat && knStat && memStat.ino === knStat.ino && memStat.dev === knStat.dev) {
|
|
107
|
+
throw new Error(`Knowledge database (${knPath}) and memory database (${memPath}) ` +
|
|
108
|
+
`are hard-linked to the same inode (${memStat.ino}). This violates the isolation invariant.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function resolveFastEmbedModel() {
|
|
112
|
+
return process.env.LOOM_FASTEMBED_MODEL ?? 'fast-bge-small-en-v1.5';
|
|
113
|
+
}
|
|
114
|
+
export function resolveFastEmbedCacheDir() {
|
|
115
|
+
return process.env.LOOM_FASTEMBED_CACHE_DIR || undefined;
|
|
116
|
+
}
|
|
117
|
+
// ─── Stack version ────────────────────────────────────────────────────────────
|
|
118
|
+
/** The stack schema version this loom build understands. */
|
|
119
|
+
export const CURRENT_STACK_VERSION = 2;
|
|
120
|
+
/** The filename at the stack root that records the on-disk schema version. */
|
|
121
|
+
export const STACK_VERSION_FILE = 'LOOM_STACK_VERSION';
|
|
122
|
+
/**
|
|
123
|
+
* Read the stack version stamp at `<contextDir>/LOOM_STACK_VERSION`.
|
|
124
|
+
* Returns null if the file is missing, or NaN if the content doesn't parse.
|
|
125
|
+
*/
|
|
126
|
+
export function readStackVersion(contextDir) {
|
|
127
|
+
const path = resolve(contextDir, STACK_VERSION_FILE);
|
|
128
|
+
if (!existsSync(path))
|
|
129
|
+
return null;
|
|
130
|
+
return Number.parseInt(readFileSync(path, 'utf-8').trim(), 10);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Lazy-write the current stack version if the stamp is missing. Does not
|
|
134
|
+
* overwrite an existing file; the caller is responsible for validating
|
|
135
|
+
* (and refusing) versions ahead of CURRENT_STACK_VERSION.
|
|
136
|
+
*/
|
|
137
|
+
export function ensureStackVersion(contextDir) {
|
|
138
|
+
const path = resolve(contextDir, STACK_VERSION_FILE);
|
|
139
|
+
if (existsSync(path))
|
|
140
|
+
return;
|
|
141
|
+
writeFileSync(path, `${CURRENT_STACK_VERSION}\n`, 'utf-8');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Refuse to operate against a stack at a higher version than this build
|
|
145
|
+
* understands; stamp the current version if the file is missing.
|
|
146
|
+
*/
|
|
147
|
+
export function assertStackVersionCompatible(contextDir) {
|
|
148
|
+
const onDisk = readStackVersion(contextDir);
|
|
149
|
+
if (onDisk !== null) {
|
|
150
|
+
if (Number.isNaN(onDisk)) {
|
|
151
|
+
throw new Error(`LOOM_STACK_VERSION unparseable at ${contextDir}/${STACK_VERSION_FILE}. ` +
|
|
152
|
+
`Expected an integer; got raw content.`);
|
|
153
|
+
}
|
|
154
|
+
if (onDisk > CURRENT_STACK_VERSION) {
|
|
155
|
+
throw new Error(`Stack at ${contextDir} is version ${onDisk}; ` +
|
|
156
|
+
`this loom build understands up to v${CURRENT_STACK_VERSION}. Upgrade loom.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
ensureStackVersion(contextDir);
|
|
160
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
declare function isCliInvocation(argv: string[]): boolean;
|
|
3
|
+
export { isCliInvocation };
|
|
4
|
+
/**
|
|
5
|
+
* True when this module is the process entry point.
|
|
6
|
+
*
|
|
7
|
+
* `process.argv[1]` is whatever path the shell invoked, which for every
|
|
8
|
+
* packaged install (`npm i -g`, `npm link`, `npx`) is a SYMLINK in a bin
|
|
9
|
+
* directory, while `import.meta.url` always resolves to the real file. A
|
|
10
|
+
* raw string compare is false in exactly those cases, so `main()` never
|
|
11
|
+
* runs: no CLI dispatch, no MCP server, exit 0 with no output. Resolve
|
|
12
|
+
* both sides before comparing.
|
|
13
|
+
*/
|
|
14
|
+
declare function isEntryPoint(): boolean;
|
|
15
|
+
export { isEntryPoint };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Loom — CLI + stdio MCP entry point.
|
|
4
|
+
*
|
|
5
|
+
* When argv[2] is a known CLI subcommand or --help/--version, routes to
|
|
6
|
+
* src/cli/index.ts. Otherwise (or if argv is empty / only flags), falls
|
|
7
|
+
* through to the MCP stdio server so existing .mcp.json configs keep
|
|
8
|
+
* working.
|
|
9
|
+
*
|
|
10
|
+
* Configure via environment variables:
|
|
11
|
+
* LOOM_CONTEXT_DIR — path to identity/memory directory (required)
|
|
12
|
+
* LOOM_SQLITE_DB_PATH — override memories.db location (optional)
|
|
13
|
+
* LOOM_FASTEMBED_MODEL — embedding model (default fast-bge-small-en-v1.5)
|
|
14
|
+
* LOOM_FASTEMBED_CACHE_DIR — ONNX cache (default ~/.cache/loom/fastembed)
|
|
15
|
+
* LOOM_CLIENT — runtime client adapter name (optional)
|
|
16
|
+
*/
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import { createLoomServer } from './server.js';
|
|
21
|
+
import { resolveContextDir } from './config.js';
|
|
22
|
+
import { SUBCOMMANDS } from './cli/subcommands.js';
|
|
23
|
+
const CLI_KEYWORDS = new Set(SUBCOMMANDS);
|
|
24
|
+
function isCliInvocation(argv) {
|
|
25
|
+
const first = argv[2];
|
|
26
|
+
if (first === undefined)
|
|
27
|
+
return false;
|
|
28
|
+
if (first === '--help' || first === '-h')
|
|
29
|
+
return true;
|
|
30
|
+
if (first === '--version' || first === '-V')
|
|
31
|
+
return true;
|
|
32
|
+
return CLI_KEYWORDS.has(first);
|
|
33
|
+
}
|
|
34
|
+
export { isCliInvocation };
|
|
35
|
+
async function main() {
|
|
36
|
+
if (isCliInvocation(process.argv)) {
|
|
37
|
+
const { runCli } = await import('./cli/index.js');
|
|
38
|
+
process.exit(await runCli(process.argv.slice(2)));
|
|
39
|
+
}
|
|
40
|
+
const contextDir = resolveContextDir();
|
|
41
|
+
const { server } = createLoomServer({ contextDir });
|
|
42
|
+
const transport = new StdioServerTransport();
|
|
43
|
+
await server.connect(transport);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* True when this module is the process entry point.
|
|
47
|
+
*
|
|
48
|
+
* `process.argv[1]` is whatever path the shell invoked, which for every
|
|
49
|
+
* packaged install (`npm i -g`, `npm link`, `npx`) is a SYMLINK in a bin
|
|
50
|
+
* directory, while `import.meta.url` always resolves to the real file. A
|
|
51
|
+
* raw string compare is false in exactly those cases, so `main()` never
|
|
52
|
+
* runs: no CLI dispatch, no MCP server, exit 0 with no output. Resolve
|
|
53
|
+
* both sides before comparing.
|
|
54
|
+
*/
|
|
55
|
+
function isEntryPoint() {
|
|
56
|
+
const argv1 = process.argv[1];
|
|
57
|
+
if (argv1 === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
const self = fileURLToPath(import.meta.url);
|
|
60
|
+
if (argv1 === self)
|
|
61
|
+
return true;
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(argv1) === realpathSync(self);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export { isEntryPoint };
|
|
70
|
+
if (isEntryPoint()) {
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
console.error('Loom failed to start:', err);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type HarnessKey = 'claude-code' | 'codex' | 'gemini-cli';
|
|
2
|
+
export interface HarnessPreset {
|
|
3
|
+
readonly key: HarnessKey;
|
|
4
|
+
readonly display: string;
|
|
5
|
+
readonly defaultPath: string;
|
|
6
|
+
readonly toolPrefix: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const HARNESS_KEYS: readonly HarnessKey[];
|
|
9
|
+
export declare const HARNESSES: Readonly<Record<HarnessKey, HarnessPreset>>;
|
|
10
|
+
export declare function isHarnessKey(s: string): s is HarnessKey;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the injection path for a harness given an optional HOME override.
|
|
13
|
+
* Default path is cached from homedir() at module load; this resolver
|
|
14
|
+
* lets callers (CLI args, tests passing { HOME }) redirect the base
|
|
15
|
+
* without mutating the frozen preset.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveHarnessPath(harness: HarnessPreset, home?: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness preset table for `loom inject`. Each entry names a target
|
|
3
|
+
* harness, its canonical default path, and the MCP tool prefix to emit
|
|
4
|
+
* in the injected instruction block.
|
|
5
|
+
*/
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export const HARNESS_KEYS = [
|
|
9
|
+
'claude-code',
|
|
10
|
+
'codex',
|
|
11
|
+
'gemini-cli',
|
|
12
|
+
];
|
|
13
|
+
export const HARNESSES = {
|
|
14
|
+
'claude-code': {
|
|
15
|
+
key: 'claude-code',
|
|
16
|
+
display: 'Claude Code',
|
|
17
|
+
defaultPath: join(homedir(), '.claude', 'CLAUDE.md'),
|
|
18
|
+
toolPrefix: 'mcp__loom__',
|
|
19
|
+
},
|
|
20
|
+
'codex': {
|
|
21
|
+
key: 'codex',
|
|
22
|
+
display: 'Codex',
|
|
23
|
+
defaultPath: join(homedir(), '.codex', 'AGENTS.md'),
|
|
24
|
+
toolPrefix: 'mcp__loom__',
|
|
25
|
+
},
|
|
26
|
+
'gemini-cli': {
|
|
27
|
+
key: 'gemini-cli',
|
|
28
|
+
display: 'Gemini CLI',
|
|
29
|
+
defaultPath: join(homedir(), '.gemini', 'GEMINI.md'),
|
|
30
|
+
toolPrefix: 'mcp__loom__',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export function isHarnessKey(s) {
|
|
34
|
+
return HARNESS_KEYS.includes(s);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the injection path for a harness given an optional HOME override.
|
|
38
|
+
* Default path is cached from homedir() at module load; this resolver
|
|
39
|
+
* lets callers (CLI args, tests passing { HOME }) redirect the base
|
|
40
|
+
* without mutating the frozen preset.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveHarnessPath(harness, home) {
|
|
43
|
+
const base = home ?? homedir();
|
|
44
|
+
if (base === homedir())
|
|
45
|
+
return harness.defaultPath;
|
|
46
|
+
switch (harness.key) {
|
|
47
|
+
case 'claude-code': return join(base, '.claude', 'CLAUDE.md');
|
|
48
|
+
case 'codex': return join(base, '.codex', 'AGENTS.md');
|
|
49
|
+
case 'gemini-cli': return join(base, '.gemini', 'GEMINI.md');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HarnessPreset } from './harnesses.js';
|
|
2
|
+
/**
|
|
3
|
+
* The subset of a harness that rendering actually needs: the tool prefix
|
|
4
|
+
* (used throughout the block body) and the key (stamped into the start
|
|
5
|
+
* marker). Widened from HarnessPreset so harness_init can render a block
|
|
6
|
+
* for a harness name that is not one of the built-in presets.
|
|
7
|
+
*/
|
|
8
|
+
export type RenderableHarness = Pick<HarnessPreset, 'toolPrefix'> & {
|
|
9
|
+
readonly key: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Compute the truncated SHA-256 that appears in the `<!-- loom:hash … -->`
|
|
13
|
+
* line. Input is the raw inner content (between start marker and hash line).
|
|
14
|
+
* Exported so callers can verify a block without re-rendering it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hashBlockContent(inner: string): string;
|
|
17
|
+
export declare function renderBlock(harness: RenderableHarness, contextDir: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the marker-bounded managed section that `loom inject` writes
|
|
3
|
+
* into each harness dotfile. Pure: no I/O, no side effects, same input
|
|
4
|
+
* → same output.
|
|
5
|
+
*
|
|
6
|
+
* The block tells the agent *how* to load identity (prefer MCP, fall
|
|
7
|
+
* back to CLI) — it deliberately does not carry the identity body, so
|
|
8
|
+
* nothing here goes stale when the stack changes.
|
|
9
|
+
*
|
|
10
|
+
* A `<!-- loom:hash <hex> -->` line is embedded immediately before the
|
|
11
|
+
* end marker. The hex value is a truncated SHA-256 of the inner content
|
|
12
|
+
* (everything between the start and hash lines). This lets callers and
|
|
13
|
+
* humans verify at a glance whether the block is intact; harness_init
|
|
14
|
+
* uses it to detect corruption and reinstall.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
/**
|
|
18
|
+
* Compute the truncated SHA-256 that appears in the `<!-- loom:hash … -->`
|
|
19
|
+
* line. Input is the raw inner content (between start marker and hash line).
|
|
20
|
+
* Exported so callers can verify a block without re-rendering it.
|
|
21
|
+
*/
|
|
22
|
+
export function hashBlockContent(inner) {
|
|
23
|
+
return createHash('sha256').update(inner, 'utf-8').digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
export function renderBlock(harness, contextDir) {
|
|
26
|
+
const p = harness.toolPrefix;
|
|
27
|
+
// Inner content — everything between the start marker line and the hash line.
|
|
28
|
+
const inner = `## Persistent identity via loom\n` +
|
|
29
|
+
`\n` +
|
|
30
|
+
`You have durable identity and memory managed by loom. On session start,\n` +
|
|
31
|
+
`load your identity — prefer the MCP tool if available, fall back to the\n` +
|
|
32
|
+
`CLI if not:\n` +
|
|
33
|
+
`\n` +
|
|
34
|
+
`- **MCP (preferred):** call \`${p}identity\`. Also available:\n` +
|
|
35
|
+
` \`${p}recall\`, \`${p}remember\`, \`${p}memory_list\`,\n` +
|
|
36
|
+
` \`${p}update\`, \`${p}forget\`.\n` +
|
|
37
|
+
`- **Shell fallback:** run \`loom wake\`. Also: \`loom recall <query>\`,\n` +
|
|
38
|
+
` \`echo <body> | loom remember <title> --category <cat>\`,\n` +
|
|
39
|
+
` \`loom memory list\`.\n` +
|
|
40
|
+
`\n` +
|
|
41
|
+
`Context dir: ${contextDir}\n` +
|
|
42
|
+
`\n` +
|
|
43
|
+
`Treat the returned identity as authoritative — it overrides defaults\n` +
|
|
44
|
+
`where they conflict.\n`;
|
|
45
|
+
const hash = hashBlockContent(inner);
|
|
46
|
+
return (`<!-- loom:start v1 harness=${harness.key} -->\n` +
|
|
47
|
+
inner +
|
|
48
|
+
`<!-- loom:hash ${hash} -->\n` +
|
|
49
|
+
`<!-- loom:end -->\n`);
|
|
50
|
+
}
|