@drdeeks/character-kit 1.0.1
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/LICENSE +21 -0
- package/README.md +178 -0
- package/deploy/ack_monitor.py +140 -0
- package/deploy/ack_watchdog.py +110 -0
- package/deploy/agent-character-monitor.service +21 -0
- package/deploy/agent-character-watchdog.service +21 -0
- package/deploy/agent-enforcer-proof.service +18 -0
- package/deploy/agent-enforcer.service +53 -0
- package/deploy/deploy-ack-services.sh +47 -0
- package/deploy/deploy-agent-enforcer.sh +133 -0
- package/deploy/proof-self-respawn.sh +59 -0
- package/node/bin/ack.js +383 -0
- package/node/bin/aik.js +520 -0
- package/node/bin/install.js +409 -0
- package/node/enforcer/agent_enforcer_daemon.js +690 -0
- package/node/examples/.agent/constitution.yaml +38 -0
- package/node/examples/.agent/enforcer.yaml +33 -0
- package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
- package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
- package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
- package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
- package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
- package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
- package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
- package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
- package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
- package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
- package/node/examples/.agent/habits/due-diligence.yaml +16 -0
- package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
- package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
- package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
- package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
- package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
- package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
- package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
- package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
- package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
- package/node/examples/.agent/habits/no-deception.yaml +14 -0
- package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
- package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
- package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
- package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
- package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
- package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
- package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
- package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
- package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
- package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
- package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
- package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
- package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
- package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
- package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
- package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
- package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
- package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
- package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
- package/node/package.json +53 -0
- package/node/src/enforcer/client.js +149 -0
- package/node/src/hooks/character.js +286 -0
- package/node/src/index.js +24 -0
- package/node/src/knowledge/indexer.js +486 -0
- package/node/src/knowledge/semantic.js +154 -0
- package/node/src/memory/index.js +355 -0
- package/node/tests/character.test.js +80 -0
- package/node/tests/habit-create.test.js +67 -0
- package/node/tests/habits-default.test.js +68 -0
- package/node/tests/install-chain.test.js +19 -0
- package/node/tests/install.test.js +104 -0
- package/package.json +65 -0
- package/python/agent_character_kit/__init__.py +388 -0
- package/python/agent_character_kit/__main__.py +333 -0
- package/python/agent_character_kit/_yaml.py +33 -0
- package/python/agent_character_kit/enforcer.py +379 -0
- package/python/agent_character_kit/memory.py +257 -0
- package/python/agent_character_kit/semantic.py +126 -0
- package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
- package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
- package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
- package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
- package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
- package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
- package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
- package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
- package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
- package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
- package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
- package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
- package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
- package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
- package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
- package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
- package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
- package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
- package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
- package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
- package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
- package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
- package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
- package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
- package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
- package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
- package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
- package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
- package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
- package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
- package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
- package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
- package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
- package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
- package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
- package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
- package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
- package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
- package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
- package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
- package/python/hermes_plugin/README.md +63 -0
- package/python/hermes_plugin/__init__.py +367 -0
- package/python/hermes_plugin/config.yaml +20 -0
- package/python/hermes_plugin/plugin.yaml +7 -0
- package/python/hermes_plugin/test_plugin.py +166 -0
- package/python/pyproject.toml +19 -0
- package/python/tests/python_parity_test.py +84 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { resolveSocket } from "../bin/install.js";
|
|
9
|
+
|
|
10
|
+
const REPO = path.resolve(process.cwd());
|
|
11
|
+
const DAEMON = path.join(REPO, "node", "enforcer", "agent_enforcer_daemon.js");
|
|
12
|
+
|
|
13
|
+
// ─── install.js socket resolution (pure, no spawn) ───────────────────────────
|
|
14
|
+
|
|
15
|
+
test("resolveSocket: unix mode -> workspace-relative path", () => {
|
|
16
|
+
const ws = "/tmp/myagent";
|
|
17
|
+
assert.equal(resolveSocket("unix", ws), path.join(ws, ".agent", "enforcer.sock"));
|
|
18
|
+
assert.equal(resolveSocket("1", ws), path.join(ws, ".agent", "enforcer.sock"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resolveSocket: tcp mode -> loopback url", () => {
|
|
22
|
+
assert.equal(resolveSocket("tcp", "/tmp/x"), "tcp://127.0.0.1:8753");
|
|
23
|
+
assert.equal(resolveSocket("2", "/tmp/x"), "tcp://127.0.0.1:8753");
|
|
24
|
+
assert.equal(resolveSocket("tcp://10.0.0.1:9000", "/tmp/x"), "tcp://10.0.0.1:9000");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ─── daemon reuse-window (integration: boot daemon, exercise submitAck) ───────
|
|
28
|
+
|
|
29
|
+
function rpc(sock, method, params) {
|
|
30
|
+
return new Promise((res, rej) => {
|
|
31
|
+
const c = net.connect(sock, () => {
|
|
32
|
+
c.write(JSON.stringify({ method, params }) + "\n");
|
|
33
|
+
});
|
|
34
|
+
let buf = "";
|
|
35
|
+
c.on("data", (d) => {
|
|
36
|
+
buf += d.toString();
|
|
37
|
+
if (buf.includes("\n")) {
|
|
38
|
+
c.end();
|
|
39
|
+
try { res(JSON.parse(buf.split("\n")[0])); } catch (e) { rej(e); }
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
c.on("error", rej);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("daemon: reuse-window rejects the previous two habits", { timeout: 25000 }, async () => {
|
|
47
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), "ackrw-"));
|
|
48
|
+
const sock = path.join(ws, ".agent", "enforcer.sock");
|
|
49
|
+
const env = { ...process.env, AGENT_WORKSPACE: ws, ENFORCER_SOCKET: sock, HOME: os.homedir() };
|
|
50
|
+
fs.mkdirSync(path.join(ws, ".agent", "habits"), { recursive: true });
|
|
51
|
+
|
|
52
|
+
const { spawn } = await import("node:child_process");
|
|
53
|
+
const child = spawn(process.execPath, [DAEMON], { env, detached: true, stdio: "ignore" });
|
|
54
|
+
child.unref();
|
|
55
|
+
|
|
56
|
+
// Seed the workspace with the kit's real habit files so the daemon knows all
|
|
57
|
+
// of them (mirrors what `ack install` does). Without this, only the single
|
|
58
|
+
// embedded default habit exists and the window test can't use 3 distinct habits.
|
|
59
|
+
const repoHabits = path.join(REPO, "python", "example_workspace", ".agent", "habits");
|
|
60
|
+
const wsHabits = path.join(ws, ".agent", "habits");
|
|
61
|
+
if (fs.existsSync(repoHabits)) {
|
|
62
|
+
for (const f of fs.readdirSync(repoHabits)) {
|
|
63
|
+
if (f.endsWith(".yaml")) fs.copyFileSync(path.join(repoHabits, f), path.join(wsHabits, f));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// unique session so no residual state from a prior run can interfere
|
|
68
|
+
const sid = "test-" + path.basename(ws);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// poll for the socket instead of a fixed sleep (host-speed independent)
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
while (!fs.existsSync(sock) && Date.now() - start < 8000) {
|
|
74
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
75
|
+
}
|
|
76
|
+
assert.ok(fs.existsSync(sock), "daemon socket should be up before RPCs");
|
|
77
|
+
|
|
78
|
+
// Give daemon extra time to fully initialize
|
|
79
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
80
|
+
|
|
81
|
+
const a = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: no_credential_leak why: it applies because this test spawns a real daemon and must not leak its socket path in logs" });
|
|
82
|
+
const b = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: complete_thoroughly resonates true — it ensures proper scope because the window test must exercise three distinct embedded-backed habits, not a lucky pair" });
|
|
83
|
+
assert.equal(a.ok, true);
|
|
84
|
+
assert.equal(b.ok, true);
|
|
85
|
+
|
|
86
|
+
const reuseA = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: no_credential_leak why: it applies because this test spawns a real daemon and must not leak its socket path in logs" });
|
|
87
|
+
const reuseB = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: complete_thoroughly resonates true — it ensures proper scope because the window test must exercise three distinct embedded-backed habits, not a lucky pair" });
|
|
88
|
+
assert.equal(reuseA.ok, false, "reusing no_credential_leak (in previous two) must be rejected");
|
|
89
|
+
assert.equal(reuseB.ok, false, "reusing complete_thoroughly (in previous two) must be rejected");
|
|
90
|
+
|
|
91
|
+
const c = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: rigorous_commits_no_push because it matters that this third ack uses a different closer and a genuinely different reason than the first two" });
|
|
92
|
+
assert.equal(c.ok, true);
|
|
93
|
+
const aAgain = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: no_credential_leak applies because the window has shifted and the daemon now accepts this habit again with fresh reasoning" });
|
|
94
|
+
assert.equal(aAgain.ok, true, "no_credential_leak freed after window shifted");
|
|
95
|
+
|
|
96
|
+
// filler must be rejected under the new grammar
|
|
97
|
+
const filler = await rpc(sock, "submit_ack", { session_id: sid, statement: "Habit: no_credential_leak why: yes" });
|
|
98
|
+
assert.equal(filler.ok, false, "filler reason (too short) must be rejected");
|
|
99
|
+
} finally {
|
|
100
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
101
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
102
|
+
fs.rmSync(ws, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drdeeks/character-kit",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Universal agent character enforcement, knowledge indexing, and memory with YAML frontmatter. True plugin: install once, reference as @character-kit in any harness config.",
|
|
5
|
+
"main": "node/src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ack": "node/bin/ack.js",
|
|
8
|
+
"character-kit": "node/bin/ack.js"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test node/tests/*.test.js",
|
|
13
|
+
"start": "node bin/aik.js",
|
|
14
|
+
"install": "node node/bin/install.js",
|
|
15
|
+
"ack-install": "node node/bin/install.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"agent",
|
|
19
|
+
"character",
|
|
20
|
+
"agent-character",
|
|
21
|
+
"enforcement",
|
|
22
|
+
"knowledge",
|
|
23
|
+
"memory",
|
|
24
|
+
"semantic-search",
|
|
25
|
+
"hooks",
|
|
26
|
+
"yaml",
|
|
27
|
+
"frontmatter",
|
|
28
|
+
"claude",
|
|
29
|
+
"cursor",
|
|
30
|
+
"gemini",
|
|
31
|
+
"hermes",
|
|
32
|
+
"opencode"
|
|
33
|
+
],
|
|
34
|
+
"author": "drdeeks",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"gray-matter": "^4.0.3",
|
|
38
|
+
"js-yaml": "^4.1.0",
|
|
39
|
+
"glob": "^10.3.0",
|
|
40
|
+
"commander": "^11.1.0"
|
|
41
|
+
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"@xenova/transformers": "^2.17.0"
|
|
44
|
+
},
|
|
45
|
+
"overrides": {
|
|
46
|
+
"onnxruntime-web": "^1.27.0",
|
|
47
|
+
"onnx-proto": "^8.0.1",
|
|
48
|
+
"protobufjs": "^7.2.4"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.0.0"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"node/bin/",
|
|
55
|
+
"node/src/",
|
|
56
|
+
"node/enforcer/",
|
|
57
|
+
"node/examples/",
|
|
58
|
+
"node/tests/",
|
|
59
|
+
"node/package.json",
|
|
60
|
+
"python/",
|
|
61
|
+
"deploy/",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Character Kit — Python module
|
|
3
|
+
|
|
4
|
+
Provides the same document-indexing capabilities as the Node.js package:
|
|
5
|
+
- Broad file-type discovery (markdown, code, config, agent docs, etc.)
|
|
6
|
+
- Automatic YAML frontmatter injection
|
|
7
|
+
- Link / reference extraction (the agent documents links to informational docs)
|
|
8
|
+
- llms.txt / agents.md manifest parsing
|
|
9
|
+
- Optional link-following to also index referenced local files
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from agent_character_kit import DocumentIndexer
|
|
13
|
+
idx = DocumentIndexer("/path/to/workspace")
|
|
14
|
+
idx.index_directory("./docs")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
|
|
22
|
+
# ─── Supported File Extensions ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
EXTENSIONS = {
|
|
25
|
+
"markdown": [".md", ".mdx", ".mdown", ".markdown"],
|
|
26
|
+
"text": [".txt", ".text", ".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex"],
|
|
27
|
+
"wiki": [".wiki", ".mediawiki", ".dokuwiki", ".tiddlywiki"],
|
|
28
|
+
"yaml": [".yaml", ".yml"],
|
|
29
|
+
"json": [".json", ".jsonl", ".json5", ".ndjson", ".geojson"],
|
|
30
|
+
"toml": [".toml", ".ini", ".cfg", ".conf"],
|
|
31
|
+
"xml": [".xml", ".xaml", ".svg", ".html", ".htm", ".xhtml"],
|
|
32
|
+
"csv": [".csv", ".tsv", ".psv"],
|
|
33
|
+
"code": [".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".bash",
|
|
34
|
+
".zsh", ".fish", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c",
|
|
35
|
+
".cpp", ".h", ".hpp", ".cs", ".sql", ".r", ".lua", ".pl", ".perl",
|
|
36
|
+
".vim", ".el", ".lisp", ".clj"],
|
|
37
|
+
"agent": [".agent", ".skill", ".hook", ".prompt", ".template"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Exact-match filenames (case-insensitive)
|
|
41
|
+
EXACT_NAMES = {
|
|
42
|
+
"llms.txt", "llms-full.txt", "agents.md", "agent.md",
|
|
43
|
+
"identity.md", "constitution.md", "user.md", "tools.md", "memory.md",
|
|
44
|
+
"heartbeat.md", "system.md", ".agent", ".skill", ".hook", ".prompt", ".template",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The agent's OWN files (identity, habits, memory, knowledge graph) are never
|
|
48
|
+
# indexed as user-supplied corpus.
|
|
49
|
+
AGENT_INTERNAL_FILES = {
|
|
50
|
+
"soul.md", "identity.md", "constitution.md", "agents.md", "user.md",
|
|
51
|
+
"tools.md", "memory.md", "heartbeat.md", "constitution.yaml",
|
|
52
|
+
"constitution.yml", "enforcer.yaml", "genesis.md", "readme.md",
|
|
53
|
+
}
|
|
54
|
+
AGENT_INTERNAL_DIRS = {
|
|
55
|
+
".agent", "habits", "memory", "knowledge", ".secrets",
|
|
56
|
+
"node_modules", ".git", ".agent-character-kit",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ALL_EXTENSIONS = sorted(
|
|
60
|
+
{ext for group in EXTENSIONS.values() for ext in group}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
CATEGORY_RULES = [
|
|
64
|
+
(r"SOUL|IDENTITY|CONSTITUTION", "identity"),
|
|
65
|
+
(r"AGENTS|TOOLS|HEARTBEAT|USER\.md", "agent_config"),
|
|
66
|
+
(r"MEMORY|JOURNAL|LOG", "memory"),
|
|
67
|
+
(r"README|CHANGELOG|LICENSE|CONTRIBUTING", "documentation"),
|
|
68
|
+
(r"TODO|TASKS|BACKLOG", "task_list"),
|
|
69
|
+
(r"SKILL|TUTORIAL|GUIDE|HOWTO|LEARN", "skill"),
|
|
70
|
+
(r"daily|journal|log", "daily_note"),
|
|
71
|
+
(r"transcript|session|conversation", "transcript"),
|
|
72
|
+
(r"blog|post|article", "blog"),
|
|
73
|
+
(r"experiment|spike|research", "experiment"),
|
|
74
|
+
(r"knowledge|entity|person|company", "knowledge_graph"),
|
|
75
|
+
(r"lesson|pattern|decision|learning", "long_term"),
|
|
76
|
+
(r"spec|rfc|adr|decision", "spec"),
|
|
77
|
+
(r"doc|docs|reference", "documentation"),
|
|
78
|
+
(r"config|settings|env", "config"),
|
|
79
|
+
(r"test|spec|__tests__", "test"),
|
|
80
|
+
(r"skill|hook|prompt|template", "agent_skill"),
|
|
81
|
+
(r"^---\n[\s\S]*?type:\s*person", "knowledge_graph"),
|
|
82
|
+
(r"^---\n[\s\S]*?tags?:\s*\[", "tagged"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_indexable(name: str, extensions=None) -> bool:
|
|
87
|
+
extensions = extensions or ALL_EXTENSIONS
|
|
88
|
+
lower = name.lower()
|
|
89
|
+
if lower in AGENT_INTERNAL_FILES:
|
|
90
|
+
return False
|
|
91
|
+
if lower in EXACT_NAMES:
|
|
92
|
+
return True
|
|
93
|
+
return Path(name).suffix.lower() in extensions
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def infer_category(file_path: str, content: str) -> str:
|
|
97
|
+
head = content[:500]
|
|
98
|
+
for pattern, category in CATEGORY_RULES:
|
|
99
|
+
if re.search(pattern, file_path, re.IGNORECASE) or re.search(pattern, head):
|
|
100
|
+
return category
|
|
101
|
+
return "document"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def chunk_text(text: str, size: int = 1000):
|
|
105
|
+
words = text.split()
|
|
106
|
+
chunks, cur = [], []
|
|
107
|
+
count = 0
|
|
108
|
+
for w in words:
|
|
109
|
+
cur.append(w)
|
|
110
|
+
count += len(w) + 1
|
|
111
|
+
if count >= size:
|
|
112
|
+
chunks.append(" ".join(cur))
|
|
113
|
+
cur, count = [], 0
|
|
114
|
+
if cur:
|
|
115
|
+
chunks.append(" ".join(cur))
|
|
116
|
+
return chunks or [""]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_tags(content: str, file_path: str):
|
|
120
|
+
tags = set()
|
|
121
|
+
base = Path(file_path).stem.lower().replace("_", " ").replace("-", " ")
|
|
122
|
+
tags.add(base)
|
|
123
|
+
for m in re.finditer(r"tags?:\s*\[([^\]]+)\]", content, re.IGNORECASE):
|
|
124
|
+
for t in re.split(r"[,\s]+", m.group(1)):
|
|
125
|
+
t = t.strip().strip('"\'')
|
|
126
|
+
if len(t) > 1:
|
|
127
|
+
tags.add(t.lower())
|
|
128
|
+
for m in re.finditer(r"#(\w+)", content):
|
|
129
|
+
tags.add(m.group(1).lower())
|
|
130
|
+
return sorted(tags)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ─── Link / Reference Extraction ─────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
LINK_PATTERNS = [
|
|
136
|
+
("markdown", re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)\s*\)")),
|
|
137
|
+
("url", re.compile(r"(?:^|[\s(])(https?://[^\s)\]]+)")),
|
|
138
|
+
("wiki", re.compile(r"\[\[\s*([^\]|#]+)(?:[|#][^\]]*)?\s*\]\]")),
|
|
139
|
+
("embed", re.compile(r"!\[\[\s*([^\]|#]+)(?:[|#][^\]]*)?\s*\]\]")),
|
|
140
|
+
("docref", re.compile(r"(?:doc|file|ref|see|import|include)\s*[:=]\s*[\"']?([^\s\"')]+)")),
|
|
141
|
+
("arxiv", re.compile(r"(?:arxiv\.org/abs/|arXiv:)(\d+\.\d+)")),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_links(content: str, base_path: Path = None):
|
|
146
|
+
found = []
|
|
147
|
+
seen = set()
|
|
148
|
+
for kind, rx in LINK_PATTERNS:
|
|
149
|
+
for m in rx.finditer(content):
|
|
150
|
+
target = m.group(1).strip().rstrip(".,;:")
|
|
151
|
+
if not target or target in seen:
|
|
152
|
+
continue
|
|
153
|
+
seen.add(target)
|
|
154
|
+
resolved = None
|
|
155
|
+
ext = target.startswith("http") or target.startswith("@") or kind == "arxiv"
|
|
156
|
+
if base_path and not ext:
|
|
157
|
+
cand = (base_path.parent / target).resolve()
|
|
158
|
+
if cand.exists():
|
|
159
|
+
resolved = str(cand)
|
|
160
|
+
found.append({
|
|
161
|
+
"type": kind,
|
|
162
|
+
"target": target,
|
|
163
|
+
"resolved_path": resolved,
|
|
164
|
+
"external": bool(ext),
|
|
165
|
+
})
|
|
166
|
+
return found
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_llms_txt(content: str, base_dir: Path = None):
|
|
170
|
+
details = []
|
|
171
|
+
for line in content.splitlines():
|
|
172
|
+
m = re.match(r"^\s*[-\*]\s*\[([^\]]+)\]\(([^)]+)\)\s*[:\-]?\s*(.*)$", line)
|
|
173
|
+
if m:
|
|
174
|
+
title, url, desc = m.group(1).strip(), m.group(2).strip(), m.group(3).strip()
|
|
175
|
+
external = url.startswith("http")
|
|
176
|
+
resolved = str((base_dir / url).resolve()) if base_dir and not external else None
|
|
177
|
+
details.append({
|
|
178
|
+
"title": title,
|
|
179
|
+
"url": url,
|
|
180
|
+
"description": desc,
|
|
181
|
+
"external": external,
|
|
182
|
+
"resolved_path": resolved,
|
|
183
|
+
})
|
|
184
|
+
return details
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ─── Document Indexer ────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
class DocumentIndexer:
|
|
190
|
+
def __init__(self, workspace: str):
|
|
191
|
+
self.workspace = Path(workspace).resolve()
|
|
192
|
+
self.knowledge_dir = self.workspace / "knowledge"
|
|
193
|
+
self.yaml_dir = self.knowledge_dir / "documents"
|
|
194
|
+
self.db_path = self.knowledge_dir / "index.json"
|
|
195
|
+
self.yaml_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
self.index = self._load()
|
|
197
|
+
|
|
198
|
+
def init(self):
|
|
199
|
+
"""Create the knowledge store dirs and (re)load the index.
|
|
200
|
+
|
|
201
|
+
Mirrors the Node DocumentIndexer.init() so both packages are at parity
|
|
202
|
+
and callers can construct, then init(), before indexing.
|
|
203
|
+
"""
|
|
204
|
+
self.knowledge_dir.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
self.yaml_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
self.index = self._load()
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
def _load(self):
|
|
210
|
+
if self.db_path.exists():
|
|
211
|
+
try:
|
|
212
|
+
return json.loads(self.db_path.read_text())
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return {"meta": {}, "documents": {}, "links": {}}
|
|
216
|
+
|
|
217
|
+
def _save(self):
|
|
218
|
+
self.index["meta"]["lastIndexed"] = datetime.now(timezone.utc).isoformat()
|
|
219
|
+
self.db_path.write_text(json.dumps(self.index, indent=2))
|
|
220
|
+
|
|
221
|
+
def index_file(self, file_path: str, options=None):
|
|
222
|
+
options = options or {}
|
|
223
|
+
abs_path = Path(file_path).resolve()
|
|
224
|
+
content = abs_path.read_text(encoding="utf-8", errors="ignore")
|
|
225
|
+
|
|
226
|
+
# strip existing frontmatter
|
|
227
|
+
body = content
|
|
228
|
+
if content.startswith("---"):
|
|
229
|
+
end = content.find("\n---", 3)
|
|
230
|
+
if end != -1:
|
|
231
|
+
body = content[end + 4:].lstrip("\n")
|
|
232
|
+
|
|
233
|
+
doc_id = str(abs_path.relative_to(self.workspace)).replace("/", "-").rsplit(".", 1)[0]
|
|
234
|
+
stat = abs_path.stat()
|
|
235
|
+
content_hash = str(stat.st_mtime_ns)
|
|
236
|
+
|
|
237
|
+
if self.index["documents"].get(doc_id, {}).get("contentHash") == content_hash:
|
|
238
|
+
return {"status": "skipped", "docId": doc_id}
|
|
239
|
+
|
|
240
|
+
category = options.get("category") or infer_category(str(abs_path), body)
|
|
241
|
+
tags = options.get("tags") or extract_tags(body, str(abs_path))
|
|
242
|
+
title = Path(abs_path).stem
|
|
243
|
+
|
|
244
|
+
frontmatter = {
|
|
245
|
+
"id": doc_id,
|
|
246
|
+
"title": title,
|
|
247
|
+
"category": category,
|
|
248
|
+
"tags": tags,
|
|
249
|
+
"type": category,
|
|
250
|
+
"source": str(abs_path.relative_to(self.workspace)),
|
|
251
|
+
"indexed_at": datetime.now(timezone.utc).isoformat(),
|
|
252
|
+
"updated_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
|
253
|
+
}
|
|
254
|
+
yaml_content = "---\n" + "\n".join(f"{k}: {json.dumps(v)}" for k, v in frontmatter.items()) + "\n---\n\n" + body
|
|
255
|
+
(self.yaml_dir / f"{doc_id}.yaml").write_text(yaml_content)
|
|
256
|
+
|
|
257
|
+
chunks = chunk_text(body)
|
|
258
|
+
links = extract_links(body, abs_path)
|
|
259
|
+
basename = abs_path.name.lower()
|
|
260
|
+
|
|
261
|
+
llms_refs = []
|
|
262
|
+
if basename in ("llms.txt", "llms-full.txt", "agents.md"):
|
|
263
|
+
llms_refs = parse_llms_txt(body, abs_path.parent)
|
|
264
|
+
for ref in llms_refs:
|
|
265
|
+
self.index["links"][ref["url"]] = {
|
|
266
|
+
"title": ref["title"],
|
|
267
|
+
"category": "reference",
|
|
268
|
+
"description": ref["description"],
|
|
269
|
+
"external": ref["external"],
|
|
270
|
+
"source_doc": doc_id,
|
|
271
|
+
"added_at": datetime.now(timezone.utc).isoformat(),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
self.index["documents"][doc_id] = {
|
|
275
|
+
"path": str(abs_path.relative_to(self.workspace)),
|
|
276
|
+
"title": title,
|
|
277
|
+
"category": category,
|
|
278
|
+
"tags": tags,
|
|
279
|
+
"contentHash": content_hash,
|
|
280
|
+
"indexed_at": datetime.now(timezone.utc).isoformat(),
|
|
281
|
+
"chunk_count": len(chunks),
|
|
282
|
+
"links": [l["target"] for l in links],
|
|
283
|
+
"link_count": len(links),
|
|
284
|
+
"chunks": [{"id": f"{doc_id}:chunk-{i}", "content": c} for i, c in enumerate(chunks)],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for l in links:
|
|
288
|
+
key = l["target"]
|
|
289
|
+
if key not in self.index["links"]:
|
|
290
|
+
self.index["links"][key] = {
|
|
291
|
+
"type": l["type"],
|
|
292
|
+
"category": "reference",
|
|
293
|
+
"external": l["external"],
|
|
294
|
+
"source_doc": doc_id,
|
|
295
|
+
"resolved_path": l["resolved_path"],
|
|
296
|
+
"added_at": datetime.now(timezone.utc).isoformat(),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
self._save()
|
|
300
|
+
return {"status": "indexed", "docId": doc_id, "chunks": len(chunks), "links": len(links)}
|
|
301
|
+
|
|
302
|
+
def index_directory(self, dir_path: str, options=None):
|
|
303
|
+
options = options or {}
|
|
304
|
+
follow_links = options.get("followLinks", True)
|
|
305
|
+
extensions = options.get("extensions") or ALL_EXTENSIONS
|
|
306
|
+
results = {"indexed": 0, "skipped": 0, "errors": 0, "links": 0}
|
|
307
|
+
seen = set()
|
|
308
|
+
|
|
309
|
+
def walk(d: Path):
|
|
310
|
+
try:
|
|
311
|
+
entries = sorted(d.iterdir())
|
|
312
|
+
except Exception:
|
|
313
|
+
return
|
|
314
|
+
for entry in entries:
|
|
315
|
+
if entry.is_dir():
|
|
316
|
+
if (not entry.name.startswith(".")
|
|
317
|
+
and entry.name not in AGENT_INTERNAL_DIRS):
|
|
318
|
+
walk(entry)
|
|
319
|
+
elif is_indexable(entry.name, extensions):
|
|
320
|
+
if entry in seen:
|
|
321
|
+
continue
|
|
322
|
+
seen.add(entry)
|
|
323
|
+
try:
|
|
324
|
+
r = self.index_file(entry, options)
|
|
325
|
+
if r["status"] == "indexed":
|
|
326
|
+
results["indexed"] += 1
|
|
327
|
+
else:
|
|
328
|
+
results["skipped"] += 1
|
|
329
|
+
if follow_links:
|
|
330
|
+
for doc in self.index["documents"].values():
|
|
331
|
+
for link in doc.get("links", []):
|
|
332
|
+
rec = self.index["links"].get(link)
|
|
333
|
+
if rec and rec.get("resolved_path"):
|
|
334
|
+
rp = Path(rec["resolved_path"])
|
|
335
|
+
if rp.exists() and rp not in seen:
|
|
336
|
+
seen.add(rp)
|
|
337
|
+
try:
|
|
338
|
+
rr = self.index_file(rp, options)
|
|
339
|
+
if rr["status"] == "indexed":
|
|
340
|
+
results["links"] += 1
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
except Exception:
|
|
344
|
+
results["errors"] += 1
|
|
345
|
+
|
|
346
|
+
walk(Path(dir_path).resolve())
|
|
347
|
+
self._save()
|
|
348
|
+
return results
|
|
349
|
+
|
|
350
|
+
def search(self, query: str, limit=10, category=None):
|
|
351
|
+
q = query.lower()
|
|
352
|
+
out = []
|
|
353
|
+
for doc_id, doc in self.index["documents"].items():
|
|
354
|
+
if category and doc.get("category") != category:
|
|
355
|
+
continue
|
|
356
|
+
for chunk in doc.get("chunks", []):
|
|
357
|
+
idx = chunk["content"].lower().find(q)
|
|
358
|
+
if idx != -1:
|
|
359
|
+
start = max(0, idx - 100)
|
|
360
|
+
end = min(len(chunk["content"]), idx + len(q) + 100)
|
|
361
|
+
out.append({
|
|
362
|
+
"docId": doc_id,
|
|
363
|
+
"path": doc["path"],
|
|
364
|
+
"title": doc["title"],
|
|
365
|
+
"category": doc["category"],
|
|
366
|
+
"tags": doc["tags"],
|
|
367
|
+
"snippet": chunk["content"][start:end],
|
|
368
|
+
})
|
|
369
|
+
break
|
|
370
|
+
return out[:limit]
|
|
371
|
+
|
|
372
|
+
def status(self):
|
|
373
|
+
return {
|
|
374
|
+
"documents": len(self.index["documents"]),
|
|
375
|
+
"chunks": sum(d.get("chunk_count", 0) for d in self.index["documents"].values()),
|
|
376
|
+
"links": len(self.index["links"]),
|
|
377
|
+
"last_indexed": self.index.get("meta", {}).get("lastIndexed"),
|
|
378
|
+
"knowledge_dir": str(self.knowledge_dir),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
from .enforcer import Enforcer, EnforcerClient
|
|
383
|
+
|
|
384
|
+
__all__ = [
|
|
385
|
+
"DocumentIndexer", "is_indexable", "extract_links", "parse_llms_txt",
|
|
386
|
+
"Memory", "DailyNotes", "WeeklyDigest", "LongTermMemory", "KnowledgeGraph",
|
|
387
|
+
"SemanticSearch", "Enforcer", "EnforcerClient",
|
|
388
|
+
]
|