om-memory-system 6.4.0 → 6.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.
@@ -12,7 +12,7 @@ export interface RecallArgs {
12
12
  /** Also read as an ID or text when `query` is empty. */
13
13
  memoryId?: string;
14
14
  content?: string;
15
- /** Comma-separated labels. Only memorisations that carry all of them match. */
15
+ /** Labels. A comma means AND and a bar means OR: `server | reactjs, api` needs api and either of the others. */
16
16
  tags?: string;
17
17
  limit?: number;
18
18
  }
@@ -24,7 +24,7 @@ interface MemorisationContext {
24
24
  export declare function runMemorise(args: MemoriseArgs, context: MemorisationContext, tags: ReturnType<typeof getTags>, settings: MemorisationSettings, linked: LinkedSearchSettings): Promise<Record<string, unknown>>;
25
25
  /**
26
26
  * Fetches memorisations by exact ID, or by text across memorisations only. `tags` keeps only
27
- * memorisations that carry every label. With `tags` and no text it returns the newest ones.
27
+ * memorisations that match every label group. With `tags` and no text it returns the newest ones.
28
28
  * An exact ID ignores `tags`. Every read covers the whole active space and its linked spaces,
29
29
  * so a memorisation is found from any folder. `rerank` (the `memorisationRerank` setting)
30
30
  * lets the reranker server reorder the text matches; each match keeps its content-only
@@ -1,7 +1,7 @@
1
1
  import { memoryClient } from "../services/client.js";
2
2
  import { findSnapshotByHostSession, getMemorisationById, listMemorisationsByTags, memorisationIdTaken, } from "../services/memorisation-store.js";
3
3
  import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
4
- import { hasAllTags, isMemorisationId, MEMORISATION_TAG, memorisationId, memorisationSizeError, parseTagList, pickRecallMatches, snapshotText, visibleTags, withProjectLabel, } from "./memorisation.js";
4
+ import { matchesTagGroups, isMemorisationId, MEMORISATION_TAG, memorisationId, memorisationSizeError, parseTagGroups, parseTagList, pickRecallMatches, snapshotText, visibleTags, withProjectLabel, } from "./memorisation.js";
5
5
  const SNAPSHOT_FIELDS = ["topic", "done", "doing", "next"];
6
6
  const BLOCKED = { success: false, error: "Private content blocked" };
7
7
  /** Strips `<private>` text. A field that is fully private is dropped. */
@@ -97,7 +97,7 @@ const LABEL_RECALL_LIMIT = 10;
97
97
  const LABEL_SEARCH_POOL = 200;
98
98
  /**
99
99
  * Fetches memorisations by exact ID, or by text across memorisations only. `tags` keeps only
100
- * memorisations that carry every label. With `tags` and no text it returns the newest ones.
100
+ * memorisations that match every label group. With `tags` and no text it returns the newest ones.
101
101
  * An exact ID ignores `tags`. Every read covers the whole active space and its linked spaces,
102
102
  * so a memorisation is found from any folder. `rerank` (the `memorisationRerank` setting)
103
103
  * lets the reranker server reorder the text matches; each match keeps its content-only
@@ -105,7 +105,7 @@ const LABEL_SEARCH_POOL = 200;
105
105
  */
106
106
  export async function runRecall(args, linked, rerank = false) {
107
107
  const input = (args.query ?? args.memoryId ?? args.content)?.trim();
108
- const wanted = parseTagList(args.tags);
108
+ const wanted = parseTagGroups(args.tags);
109
109
  if (!input && wanted.length === 0) {
110
110
  return { success: false, error: "query or tags required: a memorisation ID, text or labels" };
111
111
  }
@@ -128,7 +128,7 @@ export async function runRecall(args, linked, rerank = false) {
128
128
  if (!result.success)
129
129
  return { success: false, error: result.error };
130
130
  const mapped = (result.results ?? [])
131
- .filter((hit) => hasAllTags(hit.tags, wanted))
131
+ .filter((hit) => matchesTagGroups(hit.tags, wanted))
132
132
  .map((hit) => ({
133
133
  id: hit.id,
134
134
  content: (hit.memory || hit.chunk),
@@ -149,7 +149,7 @@ export async function runRecall(args, linked, rerank = false) {
149
149
  ...(matches.length === 0 ? { message: "No memorisation matched" } : {}),
150
150
  };
151
151
  }
152
- /** The newest memorisations that carry every label. They are not ranked, so they have no confidence. */
152
+ /** The newest memorisations that match every label group. They are not ranked, so they have no confidence. */
153
153
  async function recallByLabels(wanted, limit) {
154
154
  const listed = await listMemorisationsByTags("", wanted, "all-projects", Math.max(1, limit ?? LABEL_RECALL_LIMIT));
155
155
  if (!listed.success)
@@ -40,8 +40,22 @@ export declare const MEMORISATION_TAG = "memorisation";
40
40
  export declare function parseTagList(text: string | undefined): string[];
41
41
  /** The labels of a record: its tags without the fixed memorisation tag, in stored order. */
42
42
  export declare function visibleTags(tags: string[] | undefined): string[];
43
- /** True when the record carries every wanted tag. Case and surrounding spaces do not matter. */
44
- export declare function hasAllTags(recordTags: string[] | undefined, wanted: string[]): boolean;
43
+ /**
44
+ * The labels in use with the number of records that carry each. Labels that differ only in
45
+ * letter case or surrounding spaces count as one, shown in the spelling seen first. A record
46
+ * counts once per label. The fixed memorisation tag is left out. Most records come first, then name.
47
+ */
48
+ export declare function countLabels(recordTags: ReadonlyArray<string[] | undefined>): {
49
+ label: string;
50
+ count: number;
51
+ }[];
52
+ /**
53
+ * A recall label filter as groups. A comma means AND and a bar means OR, so `server | reactjs, api`
54
+ * is `[["server", "reactjs"], ["api"]]`: a record needs api and one of server or reactjs.
55
+ */
56
+ export declare function parseTagGroups(text: string | undefined): string[][];
57
+ /** True when each group has at least one label the record carries. Case and surrounding spaces do not matter. */
58
+ export declare function matchesTagGroups(recordTags: string[] | undefined, groups: string[][]): boolean;
45
59
  /**
46
60
  * The project name as one more label, after the fixed tag and the caller's labels. The root
47
61
  * folder name `/` is not a usable label, so a caller there gets no project label.
@@ -69,10 +69,48 @@ export function visibleTags(tags) {
69
69
  .map((tag) => tag.trim())
70
70
  .filter((tag) => tag && normaliseTag(tag) !== MEMORISATION_TAG);
71
71
  }
72
- /** True when the record carries every wanted tag. Case and surrounding spaces do not matter. */
73
- export function hasAllTags(recordTags, wanted) {
72
+ /**
73
+ * The labels in use with the number of records that carry each. Labels that differ only in
74
+ * letter case or surrounding spaces count as one, shown in the spelling seen first. A record
75
+ * counts once per label. The fixed memorisation tag is left out. Most records come first, then name.
76
+ */
77
+ export function countLabels(recordTags) {
78
+ const counts = new Map();
79
+ for (const tags of recordTags) {
80
+ const seen = new Set();
81
+ for (const label of visibleTags(tags)) {
82
+ const key = normaliseTag(label);
83
+ if (seen.has(key))
84
+ continue;
85
+ seen.add(key);
86
+ const own = counts.get(key);
87
+ if (own)
88
+ own.count += 1;
89
+ else
90
+ counts.set(key, { label, count: 1 });
91
+ }
92
+ }
93
+ return [...counts.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
94
+ }
95
+ /**
96
+ * A recall label filter as groups. A comma means AND and a bar means OR, so `server | reactjs, api`
97
+ * is `[["server", "reactjs"], ["api"]]`: a record needs api and one of server or reactjs.
98
+ */
99
+ export function parseTagGroups(text) {
100
+ const groups = (text ?? "")
101
+ .split(",")
102
+ .map((part) => [...new Set(part.split("|").map(normaliseTag).filter(Boolean))])
103
+ .filter((group) => group.length > 0);
104
+ const seen = new Set();
105
+ return groups.filter((group) => {
106
+ const key = [...group].sort().join("|");
107
+ return seen.has(key) ? false : (seen.add(key), true);
108
+ });
109
+ }
110
+ /** True when each group has at least one label the record carries. Case and surrounding spaces do not matter. */
111
+ export function matchesTagGroups(recordTags, groups) {
74
112
  const have = new Set((recordTags ?? []).map(normaliseTag));
75
- return wanted.every((tag) => have.has(normaliseTag(tag)));
113
+ return groups.every((group) => group.some((tag) => have.has(normaliseTag(tag))));
76
114
  }
77
115
  /**
78
116
  * The project name as one more label, after the fixed tag and the caller's labels. The root
@@ -85,7 +85,7 @@ export async function executeMemoryOperation(args, context) {
85
85
  },
86
86
  {
87
87
  command: "recall",
88
- description: "Fetch memorisations only: pass an ms- ID for the exact text, or text for ranked matches with a confidence score. Pass tags (the labels) to keep only memorisations that carry all of them; tags with no query lists the newest ones",
88
+ description: "Fetch memorisations only: pass an ms- ID for the exact text, or text for ranked matches with a confidence score. Pass tags (the labels) to keep only memorisations that carry all of them (comma = AND); write a|b for either label (bar = OR), such as server|reactjs,api; tags with no query lists the newest ones",
89
89
  args: ["query?", "tags?", "limit?"],
90
90
  },
91
91
  {
@@ -8,7 +8,7 @@ export interface MemorisationRouteConfig {
8
8
  }
9
9
  /**
10
10
  * Routes for the Memorisation page. Returns undefined for paths it does not own.
11
- * `GET /api/memorisations?tags=a,b` lists, `GET /api/memorisations/recall?q=&tags=` recalls
11
+ * `GET /api/memorisations?tags=a,b` lists, `GET /api/memorisations/labels` counts the labels, `GET /api/memorisations/recall?q=&tags=` recalls
12
12
  * and `POST /api/memorisations/bulk-delete` deletes. Every read covers the whole active
13
13
  * space and its linked spaces, so the page works from any folder. The general `/api/memories`
14
14
  * delete routes refuse memorisations by design, so this module owns the bulk delete.
@@ -1,4 +1,4 @@
1
- import { isMemorisationId, parseTagList, visibleTags } from "../core/memorisation.js";
1
+ import { isMemorisationId, parseTagGroups, visibleTags } from "../core/memorisation.js";
2
2
  /** Enough for any project: a memorisation is capped in size, and the user saves each on purpose. */
3
3
  const LIST_LIMIT = 200;
4
4
  /** The most IDs one bulk delete takes. */
@@ -10,16 +10,18 @@ const fail = (status, error) => ({
10
10
  });
11
11
  /**
12
12
  * Routes for the Memorisation page. Returns undefined for paths it does not own.
13
- * `GET /api/memorisations?tags=a,b` lists, `GET /api/memorisations/recall?q=&tags=` recalls
13
+ * `GET /api/memorisations?tags=a,b` lists, `GET /api/memorisations/labels` counts the labels, `GET /api/memorisations/recall?q=&tags=` recalls
14
14
  * and `POST /api/memorisations/bulk-delete` deletes. Every read covers the whole active
15
15
  * space and its linked spaces, so the page works from any folder. The general `/api/memories`
16
16
  * delete routes refuse memorisations by design, so this module owns the bulk delete.
17
17
  * Memorisation text is returned to the page and never written to the log.
18
18
  */
19
19
  export async function routeMemorisations(path, method, url, config, readBody) {
20
- const wanted = parseTagList(url.searchParams.get("tags") ?? undefined);
20
+ const wanted = parseTagGroups(url.searchParams.get("tags") ?? undefined);
21
21
  if (method === "GET" && path === "/api/memorisations")
22
22
  return listMemorisations(wanted);
23
+ if (method === "GET" && path === "/api/memorisations/labels")
24
+ return listLabels();
23
25
  if (method === "GET" && path === "/api/memorisations/recall") {
24
26
  return recall(url.searchParams, wanted, config);
25
27
  }
@@ -44,6 +46,11 @@ async function listMemorisations(wanted) {
44
46
  }));
45
47
  return ok({ memorisations });
46
48
  }
49
+ async function listLabels() {
50
+ const { listMemorisationLabels } = await import("./memorisation-store.js");
51
+ const result = await listMemorisationLabels();
52
+ return result.success ? ok({ labels: result.labels }) : fail(500, result.error);
53
+ }
47
54
  async function recall(params, wanted, config) {
48
55
  const text = params.get("q")?.trim();
49
56
  if (!text && wanted.length === 0)
@@ -52,7 +59,7 @@ async function recall(params, wanted, config) {
52
59
  const { runRecall } = await import("../core/memorisation-operations.js");
53
60
  const result = await runRecall({
54
61
  query: text,
55
- tags: wanted.join(","),
62
+ tags: wanted.map((group) => group.join("|")).join(","),
56
63
  ...(Number.isInteger(limit) && limit > 0 ? { limit } : {}),
57
64
  }, config.linked, config.rerank);
58
65
  if (result.success !== true) {
@@ -26,17 +26,28 @@ export declare function findSnapshotByHostSession(hostSessionId: string, contain
26
26
  id: string;
27
27
  } | null>;
28
28
  /**
29
- * The project's memorisations that carry every tag in `tags`, newest first, in the active
29
+ * The project's memorisations that match every group in `tags` (a comma is AND, a bar is OR), newest first, in the active
30
30
  * space and its linked spaces. The tag filter runs before `limit`, so an old labelled
31
31
  * row is not pushed out by newer rows without the label.
32
32
  */
33
- export declare function listMemorisationsByTags(containerTag: string, tags: string[], scope: MemoryScope, limit: number): Promise<{
33
+ export declare function listMemorisationsByTags(containerTag: string, tags: string[][], scope: MemoryScope, limit: number): Promise<{
34
34
  success: true;
35
35
  memories: ListedMemory[];
36
36
  } | {
37
37
  success: false;
38
38
  error: string;
39
39
  }>;
40
+ /** Every label in use across the active space and its linked spaces, with its record count. */
41
+ export declare function listMemorisationLabels(): Promise<{
42
+ success: true;
43
+ labels: {
44
+ label: string;
45
+ count: number;
46
+ }[];
47
+ } | {
48
+ success: false;
49
+ error: string;
50
+ }>;
40
51
  export type MemorisationDeleteOutcome = "deleted" | "not-found" | "linked" | "not-memorisation";
41
52
  /**
42
53
  * Deletes one memorisation from the active space. A project tag narrows the delete to that
@@ -1,4 +1,4 @@
1
- import { hasAllTags, visibleTags } from "../core/memorisation.js";
1
+ import { countLabels, matchesTagGroups, visibleTags } from "../core/memorisation.js";
2
2
  import { activeOwner } from "./active-space-access.js";
3
3
  import { readLinkedMemory } from "./linked-search.js";
4
4
  import { extractScopeFromContainerTag } from "./memory-scope.js";
@@ -78,7 +78,7 @@ export async function findSnapshotByHostSession(hostSessionId, containerTag) {
78
78
  /** Far above any real project: a memorisation is capped in size and the user saves each on purpose. */
79
79
  const READ_CEILING = 10_000;
80
80
  /**
81
- * The project's memorisations that carry every tag in `tags`, newest first, in the active
81
+ * The project's memorisations that match every group in `tags` (a comma is AND, a bar is OR), newest first, in the active
82
82
  * space and its linked spaces. The tag filter runs before `limit`, so an old labelled
83
83
  * row is not pushed out by newer rows without the label.
84
84
  */
@@ -90,12 +90,22 @@ export async function listMemorisationsByTags(containerTag, tags, scope, limit)
90
90
  if (!result.success)
91
91
  return { success: false, error: result.error };
92
92
  const memories = result.memories
93
- .filter((memory) => hasAllTags(memory.tags, tags))
93
+ .filter((memory) => matchesTagGroups(memory.tags, tags))
94
94
  // Own rows come first from the client, then linked rows. Callers want one timeline.
95
95
  .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
96
96
  .slice(0, limit);
97
97
  return { success: true, memories };
98
98
  }
99
+ /** Every label in use across the active space and its linked spaces, with its record count. */
100
+ export async function listMemorisationLabels() {
101
+ const result = await memoryClient.listMemories("", READ_CEILING, "all-projects", {
102
+ includeLinked: true,
103
+ kind: "memorisation",
104
+ });
105
+ if (!result.success)
106
+ return { success: false, error: result.error };
107
+ return { success: true, labels: countLabels(result.memories.map((memory) => memory.tags)) };
108
+ }
99
109
  /**
100
110
  * Deletes one memorisation from the active space. A project tag narrows the delete to that
101
111
  * project; `null` matches any project, the rule bulk delete uses. No row is "not-found".