@gamaze/hicortex 0.13.1 → 0.13.3

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.
@@ -16,23 +16,18 @@
16
16
  *
17
17
  * Directory layout:
18
18
  * ~/.pi/agent/sessions/
19
- * --home-agents-Agents-raider--/
19
+ * --home-alice-projects-myagent--/
20
20
  * 2026-04-10T18-37-44-615Z_<uuid>.jsonl
21
21
  * 2026-04-11T07-51-28-282Z_<uuid>.jsonl
22
22
  * --home-agents-Development-MAIC--/
23
23
  * ...
24
24
  *
25
- * The encoded-cwd uses double-dash separators: /home/agents/Agents/raider
26
- * becomes --home-agents-Agents-raider--. The session header's `cwd` field
25
+ * The encoded-cwd uses double-dash separators: /home/alice/projects/myagent
26
+ * becomes --home-alice-projects-myagent--. The session header's `cwd` field
27
27
  * is the canonical path; the directory name is a filesystem-safe encoding.
28
28
  */
29
- export interface TranscriptBatch {
30
- sessionId: string;
31
- projectName: string;
32
- date: string;
33
- entries: unknown[];
34
- sourceAgent?: string;
35
- }
29
+ import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
30
+ export type { TranscriptBatch, CursorMap };
36
31
  /**
37
32
  * Read Pi session transcripts modified after `since`.
38
33
  *
@@ -41,5 +36,7 @@ export interface TranscriptBatch {
41
36
  *
42
37
  * @param since Only return sessions with mtime > this date
43
38
  * @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
39
+ * @param cursors Per-session capture cursors (#189); default empty = whole file
40
+ * @param keyPrefix Cursor-key namespace ("pi" here; OC passes "oc:<agentId>")
44
41
  */
45
- export declare function readPiTranscripts(since: Date, sessionsDir?: string): TranscriptBatch[];
42
+ export declare function readPiTranscripts(since: Date, sessionsDir?: string, cursors?: CursorMap, keyPrefix?: string): TranscriptBatch[];
@@ -17,14 +17,14 @@
17
17
  *
18
18
  * Directory layout:
19
19
  * ~/.pi/agent/sessions/
20
- * --home-agents-Agents-raider--/
20
+ * --home-alice-projects-myagent--/
21
21
  * 2026-04-10T18-37-44-615Z_<uuid>.jsonl
22
22
  * 2026-04-11T07-51-28-282Z_<uuid>.jsonl
23
23
  * --home-agents-Development-MAIC--/
24
24
  * ...
25
25
  *
26
- * The encoded-cwd uses double-dash separators: /home/agents/Agents/raider
27
- * becomes --home-agents-Agents-raider--. The session header's `cwd` field
26
+ * The encoded-cwd uses double-dash separators: /home/alice/projects/myagent
27
+ * becomes --home-alice-projects-myagent--. The session header's `cwd` field
28
28
  * is the canonical path; the directory name is a filesystem-safe encoding.
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -41,8 +41,10 @@ const DEFAULT_PI_SESSIONS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(),
41
41
  *
42
42
  * @param since Only return sessions with mtime > this date
43
43
  * @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
44
+ * @param cursors Per-session capture cursors (#189); default empty = whole file
45
+ * @param keyPrefix Cursor-key namespace ("pi" here; OC passes "oc:<agentId>")
44
46
  */
45
- function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
47
+ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR, cursors = {}, keyPrefix = "pi") {
46
48
  const batches = [];
47
49
  let projectDirs;
48
50
  try {
@@ -80,6 +82,7 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
80
82
  const raw = (0, node_fs_1.readFileSync)(filePath, "utf-8");
81
83
  const lines = raw.split("\n").filter((l) => l.trim());
82
84
  const entries = [];
85
+ const timestamps = [];
83
86
  let sessionId = "";
84
87
  let sessionCwd = "";
85
88
  let sessionDate = "";
@@ -87,6 +90,7 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
87
90
  try {
88
91
  const entry = JSON.parse(line);
89
92
  entries.push(entry);
93
+ timestamps.push(typeof entry.timestamp === "string" ? entry.timestamp : "");
90
94
  // Extract metadata from the session header
91
95
  if (entry.type === "session") {
92
96
  sessionId = entry.id ?? "";
@@ -111,14 +115,38 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
111
115
  if (!sessionDate) {
112
116
  sessionDate = extractDateFromFilename(file) ?? "";
113
117
  }
114
- if (entries.length > 0) {
115
- batches.push({
116
- sessionId,
117
- projectName,
118
- date: sessionDate,
119
- entries,
120
- });
118
+ if (entries.length === 0)
119
+ continue;
120
+ // Incremental slice (#189): append-only JSONL v3, same discipline as CC.
121
+ const cursorKey = `${keyPrefix}:${sessionId}`;
122
+ const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
123
+ let start = pos.cursor;
124
+ let gen = pos.gen;
125
+ if (start > entries.length) {
126
+ // shrink guard (truncation/rotation) — reset + bump generation (fix 8)
127
+ start = 0;
128
+ gen = pos.gen + 1;
129
+ }
130
+ const delta = entries.slice(start);
131
+ if (delta.length === 0)
132
+ continue; // cursor already covers the file
133
+ const entryCursors = delta.map((_, i) => start + i + 1);
134
+ // Prefer the last timestamped entry in the delta for per-night dating.
135
+ let deltaDate = "";
136
+ for (let i = start; i < entries.length; i++) {
137
+ if (timestamps[i])
138
+ deltaDate = timestamps[i].slice(0, 10);
121
139
  }
140
+ batches.push({
141
+ sessionId,
142
+ projectName,
143
+ date: deltaDate || sessionDate || new Date().toISOString().slice(0, 10),
144
+ entries: delta,
145
+ cursorKey,
146
+ startCursor: start,
147
+ generation: gen,
148
+ entryCursors,
149
+ });
122
150
  }
123
151
  catch {
124
152
  // File read or parse failed — skip
@@ -129,7 +157,7 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
129
157
  }
130
158
  /**
131
159
  * Extract the last path segment from a cwd as the project name.
132
- * /home/agents/Agents/raider → "raider"
160
+ * /home/alice/projects/myagent → "myagent"
133
161
  * Falls back to decoding the directory name if cwd is empty.
134
162
  */
135
163
  function deriveProjectName(cwd, encodedDir) {
@@ -137,7 +165,7 @@ function deriveProjectName(cwd, encodedDir) {
137
165
  const segments = cwd.split("/").filter(Boolean);
138
166
  return segments[segments.length - 1] ?? "unknown";
139
167
  }
140
- // Decode the Pi directory encoding: --home-agents-Agents-raider-- → raider
168
+ // Decode the Pi directory encoding: --home-alice-projects-myagent-- → myagent
141
169
  const decoded = encodedDir.replace(/^--/, "").replace(/--$/, "").split("-");
142
170
  return decoded[decoded.length - 1] ?? "unknown";
143
171
  }
@@ -7,17 +7,38 @@
7
7
  * The reader scans for new sessions since the last nightly run
8
8
  * and feeds them to the existing distiller pipeline.
9
9
  */
10
+ import type { CursorMap } from "./capture-cursors.js";
11
+ export type { CursorMap };
10
12
  export interface TranscriptBatch {
11
13
  sessionId: string;
12
14
  projectName: string;
13
15
  date: string;
14
16
  entries: unknown[];
15
17
  /**
16
- * Optional source-agent label (e.g. "hermes/lenny"). When set, the nightly
18
+ * Optional source-agent label (e.g. "hermes/alice"). When set, the nightly
17
19
  * pipeline uses it verbatim for provenance instead of the default
18
20
  * `claude-code/<project>`. Lets per-harness readers stamp their own origin.
19
21
  */
20
22
  sourceAgent?: string;
23
+ /**
24
+ * Per-session cursor key (`<prefix>:<sessionId>`) — the capture-cursors.json
25
+ * key whose value gates and advances this session's incremental capture (#189).
26
+ */
27
+ cursorKey: string;
28
+ /** Cursor value the delta starts from (entries already captured before this run). */
29
+ startCursor: number;
30
+ /**
31
+ * Shrink-guard generation for this session — woven into segment ids so
32
+ * post-reset segments can't collide with pre-reset ids on the content-blind
33
+ * server dedup. Advanced back to the store with the cursor.
34
+ */
35
+ generation: number;
36
+ /**
37
+ * End-cursor value for each delta entry (length === entries.length). The
38
+ * packer uses these to land segment boundaries on exact entry boundaries.
39
+ * JSONL: startCursor + i + 1. Hermes: the row's messages.id.
40
+ */
41
+ entryCursors: number[];
21
42
  }
22
43
  /**
23
44
  * Cheap pre-filter: skip CC session FILES with fewer than this many raw JSONL
@@ -36,4 +57,4 @@ export declare const MIN_TRANSCRIPT_ENTRIES = 4;
36
57
  * Read all CC transcripts modified since `since`.
37
58
  * Returns one batch per session file.
38
59
  */
39
- export declare function readCcTranscripts(since: Date, projectsDir?: string): TranscriptBatch[];
60
+ export declare function readCcTranscripts(since: Date, projectsDir?: string, cursors?: CursorMap): TranscriptBatch[];
@@ -32,7 +32,7 @@ const CC_PROJECTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude
32
32
  * Read all CC transcripts modified since `since`.
33
33
  * Returns one batch per session file.
34
34
  */
35
- function readCcTranscripts(since, projectsDir = CC_PROJECTS_DIR) {
35
+ function readCcTranscripts(since, projectsDir = CC_PROJECTS_DIR, cursors = {}) {
36
36
  const batches = [];
37
37
  let projectDirs;
38
38
  try {
@@ -74,7 +74,10 @@ function readCcTranscripts(since, projectsDir = CC_PROJECTS_DIR) {
74
74
  // Skip files not modified since last run
75
75
  if (fileStat.mtime <= since)
76
76
  continue;
77
- const batch = parseTranscriptFile(filePath, projectName);
77
+ const sessionId = (0, node_path_1.basename)(filePath, ".jsonl");
78
+ const key = `cc:${sessionId}`;
79
+ const pos = cursors[key] ?? { cursor: 0, gen: 0 };
80
+ const batch = parseTranscriptFile(filePath, projectName, key, pos.cursor, pos.gen);
78
81
  if (batch) {
79
82
  batches.push(batch);
80
83
  }
@@ -83,10 +86,15 @@ function readCcTranscripts(since, projectsDir = CC_PROJECTS_DIR) {
83
86
  return batches;
84
87
  }
85
88
  /**
86
- * Parse a single .jsonl transcript file into a batch.
87
- * Returns null if the file has too few meaningful entries.
89
+ * Parse a single .jsonl transcript file into a delta batch.
90
+ *
91
+ * Returns null if the file has too few meaningful entries (whole-file gate) or
92
+ * the cursor already covers everything (nothing new since last capture, #189).
93
+ *
94
+ * @param cursorKey capture-cursors.json key for this session
95
+ * @param startCursor entries already captured (delta = entries.slice(startCursor))
88
96
  */
89
- function parseTranscriptFile(filePath, projectName) {
97
+ function parseTranscriptFile(filePath, projectName, cursorKey, startCursor, generation) {
90
98
  let raw;
91
99
  try {
92
100
  raw = (0, node_fs_1.readFileSync)(filePath, "utf-8");
@@ -98,40 +106,65 @@ function parseTranscriptFile(filePath, projectName) {
98
106
  if (lines.length < exports.MIN_TRANSCRIPT_ENTRIES)
99
107
  return null; // degenerate/empty file
100
108
  const entries = [];
101
- let lastTimestamp = "";
109
+ const timestamps = [];
102
110
  for (const line of lines) {
103
111
  try {
104
112
  const entry = JSON.parse(line);
105
113
  entries.push(entry);
106
- if (entry.timestamp) {
107
- lastTimestamp = entry.timestamp;
108
- }
114
+ timestamps.push(typeof entry.timestamp === "string" ? entry.timestamp : "");
109
115
  }
110
116
  catch {
111
- // Skip malformed lines
117
+ // Skip malformed lines — a permanently-malformed line is skipped
118
+ // identically every run, and a partial trailing write fails JSON.parse
119
+ // now and parses (at the same index) once fully flushed.
112
120
  }
113
121
  }
122
+ // Whole-file degeneracy gate stays on the full parse, not the delta.
114
123
  if (entries.length < exports.MIN_TRANSCRIPT_ENTRIES)
115
124
  return null;
116
- // Extract session ID from filename (UUID.jsonl)
117
- const sessionId = (0, node_path_1.basename)(filePath, ".jsonl");
125
+ // Shrink guard: a truncated/rotated file with fewer entries than the stored
126
+ // cursor reset to 0 AND bump the generation. The generation is woven into
127
+ // the segment id downstream so the fresh file's segments can never collide
128
+ // with the pre-reset ids on the server's content-blind dedup (fix 8).
129
+ let start = startCursor;
130
+ let gen = generation;
131
+ if (start > entries.length) {
132
+ start = 0;
133
+ gen = generation + 1;
134
+ }
135
+ const delta = entries.slice(start);
136
+ if (delta.length === 0)
137
+ return null; // cursor already covers the whole file
138
+ // Per-entry end cursors: entry i (0-based in the delta) ends at start+i+1.
139
+ const entryCursors = delta.map((_, i) => start + i + 1);
140
+ // Date from the LAST timestamped entry in the delta (per-night created_at for
141
+ // multi-day sessions), falling back to today.
142
+ let lastTimestamp = "";
143
+ for (let i = start; i < entries.length; i++) {
144
+ if (timestamps[i])
145
+ lastTimestamp = timestamps[i];
146
+ }
118
147
  return {
119
- sessionId,
148
+ sessionId: (0, node_path_1.basename)(filePath, ".jsonl"),
120
149
  projectName,
121
150
  date: lastTimestamp
122
151
  ? lastTimestamp.slice(0, 10)
123
152
  : new Date().toISOString().slice(0, 10),
124
- entries,
153
+ entries: delta,
154
+ cursorKey,
155
+ startCursor: start,
156
+ generation: gen,
157
+ entryCursors,
125
158
  };
126
159
  }
127
160
  /**
128
161
  * Decode CC project directory name to a human-readable project name.
129
- * CC uses path-based hashing: "-Users-mattias-Development-Tools-hicortex"
162
+ * CC uses path-based hashing: "-Users-alice-Development-Tools-hicortex"
130
163
  * becomes "hicortex" (last path component).
131
164
  */
132
165
  function decodeProjectDirName(dirName) {
133
166
  // CC encodes paths by replacing / with -
134
- // e.g. "-Users-mattias-Development-Tools-hicortex"
167
+ // e.g. "-Users-alice-Development-Tools-hicortex"
135
168
  const parts = dirName.split("-").filter(Boolean);
136
169
  if (parts.length === 0)
137
170
  return dirName;
@@ -9,11 +9,26 @@
9
9
  "Backfill an existing corpus with: hicortex classify-domains"
10
10
  ],
11
11
  "domains": [
12
- { "name": "Work", "description": "Your job and professional life — employer, clients, workstreams" },
13
- { "name": "Personal", "description": "Private life — home, hobbies, everyday matters" },
14
- { "name": "People", "description": "Relationshipsfamily, friends, social life, network" },
15
- { "name": "Health", "description": "Fitness, wellbeing, medical" },
16
- { "name": "Finance", "description": "Money — budgeting, spending, investing" }
12
+ {
13
+ "name": "Work",
14
+ "description": "Your job and professional life employer, clients, workstreams"
15
+ },
16
+ {
17
+ "name": "Personal",
18
+ "description": "Private life — home, hobbies, everyday matters"
19
+ },
20
+ {
21
+ "name": "People",
22
+ "description": "Relationships — family, friends, social life, network"
23
+ },
24
+ {
25
+ "name": "Health",
26
+ "description": "Fitness, wellbeing, medical"
27
+ },
28
+ {
29
+ "name": "Finance",
30
+ "description": "Money — budgeting, spending, investing"
31
+ }
17
32
  ],
18
33
  "_powerUserExample": {
19
34
  "_readme": [
@@ -22,14 +37,39 @@
22
37
  "`weakPrimaryFloor` (default 0.45) is the minimum embedding similarity for a no-fit memory to earn a weak primary; tune it from your corpus."
23
38
  ],
24
39
  "domains": [
25
- { "name": "Work", "description": "Employer, day job, client projects, workstreams", "compartment": true },
26
- { "name": "Personal", "description": "Private life — home, hobbies, everyday matters" },
27
- { "name": "People", "description": "Relationships — family, friends, social life, network" },
28
- { "name": "Health", "description": "Fitness, wellbeing, medical" },
29
- { "name": "Finance", "description": "Money — budgeting, spending, investing" },
30
- { "name": "Boating", "description": "Boats — maintenance, gear, trips, harbour life" },
31
- { "name": "Property", "description": "House and land — renovation, upkeep, garden" },
32
- { "name": "Vehicles", "description": "Cars and other vehicles service, repairs, purchases" }
40
+ {
41
+ "name": "Work",
42
+ "description": "Employer, day job, client projects, workstreams",
43
+ "compartment": true
44
+ },
45
+ {
46
+ "name": "Personal",
47
+ "description": "Private lifehome, hobbies, everyday matters"
48
+ },
49
+ {
50
+ "name": "People",
51
+ "description": "Relationships — family, friends, social life, network"
52
+ },
53
+ {
54
+ "name": "Health",
55
+ "description": "Fitness, wellbeing, medical"
56
+ },
57
+ {
58
+ "name": "Finance",
59
+ "description": "Money — budgeting, spending, investing"
60
+ },
61
+ {
62
+ "name": "Photography",
63
+ "description": "Camera gear, shoots, editing workflow, photo projects"
64
+ },
65
+ {
66
+ "name": "Home",
67
+ "description": "House, renovation projects, maintenance, garden"
68
+ },
69
+ {
70
+ "name": "Travel",
71
+ "description": "Trips, destinations, bookings, travel plans"
72
+ }
33
73
  ],
34
74
  "weakPrimaryFloor": 0.5
35
75
  }
@@ -33,8 +33,8 @@ _INJECT_CONTENT_CAP = 500
33
33
 
34
34
  # Agent ids are joined into a filesystem path server-side, so they share the
35
35
  # section-name allowlist. \Z (NOT $) anchors the END OF STRING: Python's $ also
36
- # matches just before a trailing "\n", so "nano\n" would pass and go out as
37
- # agent=nano%0A → a 400 the fail-soft path silently swallows.
36
+ # matches just before a trailing "\n", so "alice\n" would pass and go out as
37
+ # agent=alice%0A → a 400 the fail-soft path silently swallows.
38
38
  _AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*\Z")
39
39
 
40
40
 
@@ -48,7 +48,7 @@ def _sanitize_agent_id(raw: Optional[str]) -> Optional[str]:
48
48
  EXACTLY so a profile resolves to the SAME id on both harnesses (a mismatch
49
49
  would make one honor the persona firewall and the other leak global context
50
50
  into an ``off``/``override`` persona): lowercase → collapse invalid runs to
51
- "-" → strip leading -/_ → truncate 64 → validate. "Lenny" → "lenny";
51
+ "-" → strip leading -/_ → truncate 64 → validate. "Alice" → "alice";
52
52
  "MacBook-Pro.local" → "macbook-pro-local"; all-symbols → None."""
53
53
  if not isinstance(raw, str):
54
54
  return None
@@ -73,7 +73,7 @@ def _resolve_agent_name(cfg: Dict[str, Any]) -> Optional[str]:
73
73
  2. ``HERMES_PROFILE`` env;
74
74
  3. parse ``HERMES_HOME`` when it ends ``profiles/<name>``;
75
75
  4. None → bare fetch → the global set.
76
- Each source is stripped then SANITIZED (not rejected) so "Lenny" → "lenny"
76
+ Each source is stripped then SANITIZED (not rejected) so "Alice" → "alice"
77
77
  matches the TS contract; a source that sanitizes to None yields None (bare
78
78
  fetch), never a fall-through to another identity."""
79
79
  configured = (cfg.get("agent_name") or "").strip()
@@ -11,7 +11,7 @@
11
11
  "serverUrl": {
12
12
  "type": "string",
13
13
  "default": "http://127.0.0.1:8787",
14
- "description": "Hicortex server URL. Defaults to localhost (co-located server). For multi-machine setups, point this at the remote server (e.g. http://bedrock:8787 or a Tailscale HTTPS URL)."
14
+ "description": "Hicortex server URL. Defaults to localhost (co-located server). For multi-machine setups, point this at the remote server (e.g. http://your-server:8787 or an HTTPS URL)."
15
15
  },
16
16
  "authToken": {
17
17
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Self-learning memory for AI agents \u2014 experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {