@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/deploy/ack_monitor.py +140 -0
  4. package/deploy/ack_watchdog.py +110 -0
  5. package/deploy/agent-character-monitor.service +21 -0
  6. package/deploy/agent-character-watchdog.service +21 -0
  7. package/deploy/agent-enforcer-proof.service +18 -0
  8. package/deploy/agent-enforcer.service +53 -0
  9. package/deploy/deploy-ack-services.sh +47 -0
  10. package/deploy/deploy-agent-enforcer.sh +133 -0
  11. package/deploy/proof-self-respawn.sh +59 -0
  12. package/node/bin/ack.js +383 -0
  13. package/node/bin/aik.js +520 -0
  14. package/node/bin/install.js +409 -0
  15. package/node/enforcer/agent_enforcer_daemon.js +690 -0
  16. package/node/examples/.agent/constitution.yaml +38 -0
  17. package/node/examples/.agent/enforcer.yaml +33 -0
  18. package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
  19. package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
  20. package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
  21. package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
  22. package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
  23. package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
  24. package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
  25. package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
  26. package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
  27. package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
  28. package/node/examples/.agent/habits/due-diligence.yaml +16 -0
  29. package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
  30. package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  31. package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
  32. package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
  33. package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
  34. package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
  35. package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
  36. package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
  37. package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
  38. package/node/examples/.agent/habits/no-deception.yaml +14 -0
  39. package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
  40. package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
  41. package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
  42. package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
  43. package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
  44. package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
  45. package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  46. package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  47. package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
  48. package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
  49. package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
  50. package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
  51. package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
  52. package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
  53. package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
  54. package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
  55. package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
  56. package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  57. package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
  58. package/node/package.json +53 -0
  59. package/node/src/enforcer/client.js +149 -0
  60. package/node/src/hooks/character.js +286 -0
  61. package/node/src/index.js +24 -0
  62. package/node/src/knowledge/indexer.js +486 -0
  63. package/node/src/knowledge/semantic.js +154 -0
  64. package/node/src/memory/index.js +355 -0
  65. package/node/tests/character.test.js +80 -0
  66. package/node/tests/habit-create.test.js +67 -0
  67. package/node/tests/habits-default.test.js +68 -0
  68. package/node/tests/install-chain.test.js +19 -0
  69. package/node/tests/install.test.js +104 -0
  70. package/package.json +65 -0
  71. package/python/agent_character_kit/__init__.py +388 -0
  72. package/python/agent_character_kit/__main__.py +333 -0
  73. package/python/agent_character_kit/_yaml.py +33 -0
  74. package/python/agent_character_kit/enforcer.py +379 -0
  75. package/python/agent_character_kit/memory.py +257 -0
  76. package/python/agent_character_kit/semantic.py +126 -0
  77. package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
  78. package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
  79. package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
  80. package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
  81. package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
  82. package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
  83. package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
  84. package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
  85. package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
  86. package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
  87. package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
  88. package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
  89. package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  90. package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
  91. package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
  92. package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
  93. package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
  94. package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
  95. package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
  96. package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
  97. package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
  98. package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
  99. package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
  100. package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
  101. package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
  102. package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
  103. package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
  104. package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  105. package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  106. package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
  107. package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
  108. package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
  109. package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
  110. package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
  111. package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
  112. package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
  113. package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
  114. package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
  115. package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  116. package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
  117. package/python/hermes_plugin/README.md +63 -0
  118. package/python/hermes_plugin/__init__.py +367 -0
  119. package/python/hermes_plugin/config.yaml +20 -0
  120. package/python/hermes_plugin/plugin.yaml +7 -0
  121. package/python/hermes_plugin/test_plugin.py +166 -0
  122. package/python/pyproject.toml +19 -0
  123. 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
+ ]